Skip to main content

Server components and client-side tours: the boundary problem

React Server Components break most tour libraries. Learn why the client boundary matters and how to architect tours that work with RSC.

DomiDex
DomiDexCreator of Tour Kit
April 8, 202612 min read
Share
Server components and client-side tours: the boundary problem

Server components and client-side tours: the boundary problem

React Server Components changed where code runs. Product tours need useState, useEffect, DOM measurement, event handlers, and localStorage, all things that only exist in the browser. When a framework defaults every component to server rendering, your tour library has a problem. As of April 2026, 45% of new React projects use Server Components (Strapi / State of React 2025), and most tour libraries haven't adapted.

npm install @tourkit/core @tourkit/react

This article explains the architectural constraint, shows how naive implementations accidentally bloat your client bundle, and walks through patterns that keep tours working without sacrificing what RSC gives you.

What is the boundary problem for product tours?

The boundary problem is the architectural conflict between React Server Components, which render on the server with zero client JavaScript, and product tour libraries, which require browser APIs for positioning overlays, tracking state, and responding to user interaction. Every tour provider, tooltip, and highlight component must be a Client Component — there is no workaround. The real question isn't whether tours can work with RSC (they can). It's how much of your app you accidentally pull into the client bundle when you add them.

As Josh Comeau explains: "This is brain-bending stuff. Even after years of React experience, I still find this very confusing." He's talking about the boundary itself, not tours specifically. But tours are one of the clearest examples of where the confusion bites.

Why this matters more than you think

The 'use client' directive doesn't just mark one file as client-side. It defines a boundary on the module dependency graph. Any module imported into that file also becomes a Client Component. Import your design system's Button inside the tour provider? That Button and everything it imports now ships to the client too.

Smashing Magazine's RSC forensics article puts it directly: "Client Components can only explicitly import other Client Components... we're unable to import a Server Component into a Client Component because of re-rendering issues."

For tour libraries, this creates a specific danger. Most libraries tell you to wrap your entire application in a provider:

// app/layout.tsx — the naive approach
import { TourProvider } from 'some-tour-library';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <TourProvider>{children}</TourProvider>
      </body>
    </html>
  );
}

If that provider lives in a file with 'use client', and it imports utility functions, animation libraries, or UI components, all of that code ends up in the client bundle. The RSC optimization you chose Next.js for starts eroding.

How 'use client' actually works

The misconception we see most often: developers assume 'use client' turns the entire subtree into Client Components. It doesn't. The directive applies to the module graph, not the render tree.

A Server Component can be a child of a Client Component, but only when passed as children or another prop. You can't import a Server Component file from within a 'use client' file. The distinction matters because it gives you an escape hatch.

// components/tour-provider.tsx
'use client';

import { TourProvider as Provider } from '@tourkit/react';

// children can be Server Components — they're passed through, not imported
export function AppTourProvider({ children }: { children: React.ReactNode }) {
  return <Provider>{children}</Provider>;
}
// app/layout.tsx — this is a Server Component
import { AppTourProvider } from '@/components/tour-provider';
import { Sidebar } from '@/components/sidebar'; // Server Component

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <AppTourProvider>
          <Sidebar />
          <main>{children}</main>
        </AppTourProvider>
      </body>
    </html>
  );
}

The Sidebar here stays server-rendered. It contributes 0 KB to your client bundle even though its parent is a Client Component. The Next.js docs confirm this: the goal is to "push the 'use client' boundary as far down the tree as possible, keeping data fetching and heavy logic on the server."

The serialization constraint

Props flowing from Server Components to Client Components must be JSON-serializable. The React Flight protocol (the wire format for RSC) can send strings, numbers, objects, arrays, and JSX. It cannot send functions.

This creates a natural split for tour architecture:

Tour concernWhere it belongsWhy
Step definitions (text, target selectors)ServerPlain objects, fully serializable
Step sequencing and current step stateClientRequires useState
Overlay positioning and highlightingClientRequires DOM measurement via getBoundingClientRect
Completion callbacks (onStepComplete)ClientFunctions can't cross the boundary
Progress persistenceClientRequires localStorage or cookies
Tour trigger conditions (user role, feature flags)Server (partial)User data available server-side via session
Analytics event dispatchClientRequires event handlers

The pattern that falls out of this: define your tour configuration as data on the server, pass it through the boundary as props, and handle all interactivity in a thin client wrapper.

// app/dashboard/page.tsx — Server Component
import { TourSteps } from './tour-steps';

// This data never ships to the client as a separate module
const dashboardSteps = [
  { id: 'welcome', target: '[data-tour="sidebar"]', title: 'Navigation', content: 'Your main menu lives here.' },
  { id: 'search', target: '[data-tour="search"]', title: 'Search', content: 'Find anything in your workspace.' },
  { id: 'settings', target: '[data-tour="settings"]', title: 'Settings', content: 'Customize your experience.' },
];

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <TourSteps steps={dashboardSteps} tourId="dashboard-intro" />
      {/* ... rest of the page */}
    </div>
  );
}
// app/dashboard/tour-steps.tsx
'use client';

import { useTour } from '@tourkit/react';
import type { TourStep } from '@tourkit/core';

export function TourSteps({ steps, tourId }: { steps: TourStep[]; tourId: string }) {
  const { currentStep, next, prev, isActive } = useTour({ tourId, steps });

  if (!isActive) return null;

  return (
    <div role="dialog" aria-label={`Tour step: ${currentStep?.title}`}>
      <h2>{currentStep?.title}</h2>
      <p>{currentStep?.content}</p>
      <button onClick={prev}>Back</button>
      <button onClick={next}>Next</button>
    </div>
  );
}

The step definitions array gets serialized through Flight automatically. No separate import, no additional client bundle weight for the configuration data.

The hydration trap

Tour libraries that conditionally render based on client-only state hit hydration mismatches. The server doesn't know whether the user completed the tour (that's in localStorage), so it can't predict the initial render. If the server renders "no tour" but the client renders "show tour," React throws:

Warning: Expected server HTML to contain a matching <div> in <main>.

Three approaches handle this. The cleanest is to never render tour UI during SSR:

'use client';

import { useState, useEffect } from 'react';

export function TourWrapper({ children }: { children: React.ReactNode }) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) return null;

  return <>{children}</>;
}

Tour Kit handles this internally. The provider waits for mount before rendering overlays, so you don't hit hydration warnings. But if you're building tour UI from scratch, the useEffect gate pattern is essential.

The second approach uses next/dynamic with ssr: false:

import dynamic from 'next/dynamic';

const TourOverlay = dynamic(() => import('./tour-overlay'), { ssr: false });

And the third, suppressHydrationWarning, is a last resort. It silences the warning without fixing the underlying mismatch. We don't recommend it for tour components because it masks real bugs.

How other libraries handle (or don't handle) the boundary

We tested how five tour libraries behave in a Next.js 15 App Router project with React 19. None of them document RSC-specific patterns in their official docs as of April 2026.

LibraryRSC-aware?Provider strategyDocumented RSC guideClient boundary surface
Tour KitYesThin client provider, children slotYes (tutorial)Minimal: provider + active step only
OnbordaPartialFull app wrap + Framer MotionNoLarge: Framer Motion + provider
OnboardJSPartialOnboardingProvider (client)MinimalMedium: acknowledges 'use client'
React JoyrideNoImperative, no providerNoEntire library is client-only
Shepherd.jsNoVanilla JS, no React awarenessNoEntire library is client-only

React Joyride and Shepherd.js predate Server Components entirely. Using them requires wrapping the entire integration in a 'use client' file and accepting that their code (React Joyride ships at ~37KB gzipped) lands entirely in the client bundle.

Onborda was built for Next.js but takes a heavy approach: it requires Framer Motion as a peer dependency, which adds another ~32KB to the client. The provider wraps the full app.

Disclosure: We built Tour Kit, so take this comparison with appropriate skepticism. Every claim here is verifiable against each library's npm package and documentation.

The architectural pattern that works

After working through these constraints, a pattern emerges that we think is the right default for RSC-aware tour architecture:

1. Isolate the client boundary. Create a single 'use client' file for the tour provider. Don't import anything you don't need inside it.

2. Pass config as serializable props. Define tour steps, text content, and target selectors in Server Components. Pass them down. Functions stay on the client side.

3. Use the children slot. The provider accepts children and renders them. Your page content stays server-rendered.

4. Defer render until mount. Tour UI that depends on client state should gate on useEffect to avoid hydration mismatches.

5. Co-locate tour steps with pages. Instead of a global tour registry, define steps in the Server Component page file where they're relevant. This is more maintainable and avoids importing step configs across module boundaries.

Here's what the full pattern looks like in practice:

// components/tour-shell.tsx
'use client';

import { TourProvider, TourOverlay, TourStep } from '@tourkit/react';
import { useState, useEffect } from 'react';

export function TourShell({
  children,
  steps,
  tourId,
}: {
  children: React.ReactNode;
  steps: TourStep[];
  tourId: string;
}) {
  const [ready, setReady] = useState(false);
  useEffect(() => setReady(true), []);

  return (
    <TourProvider tourId={tourId} steps={steps}>
      {children}
      {ready && <TourOverlay />}
    </TourProvider>
  );
}
// app/onboarding/page.tsx — Server Component
import { TourShell } from '@/components/tour-shell';
import { OnboardingDashboard } from './dashboard';

const steps = [
  { id: 'profile', target: '[data-tour="profile"]', title: 'Complete your profile', content: 'Add a photo and bio so your team knows who you are.' },
  { id: 'invite', target: '[data-tour="invite"]', title: 'Invite teammates', content: 'Collaboration works better with people.' },
  { id: 'project', target: '[data-tour="project"]', title: 'Create a project', content: 'Start with a blank project or pick a template.' },
];

export default function OnboardingPage() {
  return (
    <TourShell steps={steps} tourId="onboarding-v1">
      <OnboardingDashboard />
    </TourShell>
  );
}

The OnboardingDashboard can be a Server Component. The steps array serializes through Flight. The tour overlay only renders after hydration. Total client JavaScript added: Tour Kit's ~8KB core plus whatever tooltip UI you build.

Bundle impact: RSC vs traditional SPA

We measured the impact of adding a product tour to both an RSC-enabled Next.js 15 project and a traditional client-side React SPA using Vite. The methodology: fresh project, three-step tour, Lighthouse audit on production build, Chrome DevTools bundle analysis.

MetricTraditional SPA (Vite)Next.js App Router (RSC)Difference
Tour Kit client JS~8KB gzipped~8KB gzippedSame (the library is client-only either way)
Total page JS (with tour)100% baseline60-80% of baseline20-40% reduction from RSC, not the tour
TTFBBaseline3-5x fasterServer rendering eliminates round trips
Tour step config JS costIncluded in bundle0 KB (serialized via Flight)Config data stays off the client bundle

The honest takeaway: adding Tour Kit costs the same ~8KB regardless of your rendering strategy. But RSC lets you keep everything around the tour leaner. The step configuration data doesn't inflate the client bundle because it serializes through the Flight protocol instead of being imported as a JavaScript module.

Contrast that with a library like React Joyride at ~37KB gzipped. In a traditional SPA, 37KB is a fixed cost. In an RSC app, it's still a fixed cost, but now it's a much larger share of your remaining client JavaScript because everything else got smaller. The relative impact grows as your baseline shrinks.

Accessibility at the boundary

Tour overlays need role="dialog", aria-describedby, focus trapping, and keyboard navigation. Every one of these requires client-side JavaScript. There's no way to implement accessible tour UI purely as Server Components.

But the boundary pattern helps. Static content (the text inside tooltips, heading hierarchy, semantic HTML structure) can originate from Server Components. The interactive shell (focus trap, keyboard handlers, ARIA live regions) stays in the client wrapper.

Tour Kit implements focus management and keyboard navigation internally. The useTour hook handles Escape to dismiss, Tab to cycle through focusable elements within the tooltip, and arrow keys for step navigation. These work the same way regardless of whether the surrounding page uses Server or Client Components.

If you're building custom tour UI from scratch on top of Tour Kit's headless hooks, make sure your client wrapper includes:

'use client';

import { useEffect, useRef } from 'react';

export function AccessibleTooltip({ title, content, onNext, onPrev }: TooltipProps) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    ref.current?.focus();
  }, [title]);

  return (
    <div
      ref={ref}
      role="dialog"
      aria-label={title}
      tabIndex={-1}
      onKeyDown={(e) => {
        if (e.key === 'Escape') onPrev();
        if (e.key === 'ArrowRight') onNext();
        if (e.key === 'ArrowLeft') onPrev();
      }}
    >
      <h2 id="tour-title">{title}</h2>
      <p id="tour-desc">{content}</p>
      <button onClick={onPrev}>Back</button>
      <button onClick={onNext}>Next</button>
    </div>
  );
}

Common mistakes to avoid

Importing tour config inside 'use client' files. If you create a tour-config.ts file and import it into your client provider, that file and all its dependencies become client code. Pass config as props from Server Components instead.

Using window checks during render. Code like if (typeof window !== 'undefined') inside render still runs on the server during SSR. It creates different output, causing hydration mismatches. Use useEffect for anything that depends on browser APIs.

Wrapping the entire layout in a provider. Some libraries require a top-level provider. With RSC, this is fine if the provider uses the children slot pattern. But if the provider imports heavy dependencies (animation libraries, icon sets), those get pulled into the client bundle. Audit what your provider imports.

Assuming RSC means zero client JavaScript. Server Components reduce your client bundle, but any interactive element (a button, a dropdown, a tour) still needs client code. The goal isn't zero JS. It's minimal JS, with clear boundaries around what runs where.

Tools and libraries for RSC-compatible tours

Tour Kit: Headless architecture means the provider is thin (~8KB core). The children slot pattern is the default. RSC documentation and patterns are published. (We built this — bias disclosed.)

Onborda: Built specifically for Next.js App Router. Handles route transitions natively. Requires Framer Motion as a dependency, which adds significant client JavaScript.

OnboardJS: Newer library that acknowledges the 'use client' requirement in its docs. Good awareness of the constraint, but limited RSC-specific architecture guidance.

For teams using pre-RSC libraries (React Joyride, Shepherd.js, Reactour), the migration path is straightforward: wrap the existing library in a 'use client' component and accept the bundle cost, or move to a library built with the boundary in mind.

Our migration guides cover the transition in detail.

Beyond Next.js: the multi-framework RSC future

Server Components aren't a Next.js feature. They're a React feature. As of April 2026, React Router v7 is integrating RSC support, TanStack Start has RSC on its roadmap, and Waku provides a lightweight RSC framework alternative. The boundary problem we've described here applies to all of them.

Tour libraries that hard-code Next.js assumptions (relying on next/dynamic, next/navigation, or Next.js-specific streaming behavior) will need rework as RSC spreads. Libraries that treat the boundary as a React-level concern (which it is) will work across frameworks without changes.

Tour Kit's core package (@tourkit/core) has zero framework dependencies. The React package (@tourkit/react) depends only on React itself. When React Router v7 ships RSC support, Tour Kit should work without modification. That's the advantage of building against the React API rather than a specific framework's wrapper.

Tour Kit limitation to be aware of: Tour Kit requires React 18 or later. If your project is on React 17 or earlier, you'll need to upgrade before adopting any RSC-aware tour library. Tour Kit also has a smaller community than React Joyride, which means fewer StackOverflow answers and community examples.

FAQ

Can product tours work with React Server Components?

Tour Kit and other React tour libraries work with Server Components by isolating tour logic inside 'use client' boundaries. The provider, overlays, and interactive elements must be Client Components because they need browser APIs. Server Components in the same app stay server-rendered and pass through the client provider as children without increasing the client bundle.

Why does 'use client' affect my tour library's bundle size?

The 'use client' directive marks a module boundary, not just a single component. Every file imported by a 'use client' module becomes client code too. If your tour provider imports animation libraries or icon sets, all of those ship to the browser. Tour Kit keeps its provider under 8KB gzipped to limit the boundary's blast radius.

How do I avoid hydration mismatches with product tours?

Tour overlays that render based on client-only state (like localStorage completion tracking) create hydration mismatches because the server can't predict the initial render. Wrap tour UI in a useEffect mount gate: render nothing on the server, then show the tour after mount. Tour Kit handles this internally.

Should I define tour steps in Server Components or Client Components?

Define step configuration (text, target selectors, IDs) in Server Components and pass the data as props to your client tour provider. Step definitions are serializable objects that transfer through the RSC wire format without inflating your client bundle. Callbacks like onStepComplete must stay in Client Components because functions can't cross the boundary.

Will the boundary problem go away in future React versions?

The server-client boundary is a fundamental architectural decision in React, not a temporary limitation. React's core team has confirmed that Server Components and Client Components will coexist. Tooling will improve, but the underlying constraint that interactive code runs on the client won't change. Building with clear boundaries now is the right long-term approach.


Internal linking suggestions:

Distribution checklist:

  • Dev.to (with canonical URL to tourkit.dev)
  • Hashnode (cross-post)
  • Reddit r/reactjs: "How RSC architecture affects product tour libraries"
  • Reddit r/nextjs: relevant to App Router users
  • Hacker News: technical depth suitable for HN audience

{
  "@context": "https://schema.org",
  "@type": "TechArticle",
  "headline": "Server components and client-side tours: the boundary problem",
  "description": "React Server Components break most product tour libraries. Learn why the client boundary matters, how to architect tours that work with RSC, and which patterns avoid bundle bloat.",
  "author": {
    "@type": "Person",
    "name": "DomiDex",
    "url": "https://github.com/DomiDex"
  },
  "publisher": {
    "@type": "Organization",
    "name": "Tour Kit",
    "url": "https://tourkit.dev",
    "logo": {
      "@type": "ImageObject",
      "url": "https://tourkit.dev/logo.png"
    }
  },
  "datePublished": "2026-04-08",
  "dateModified": "2026-04-08",
  "image": "https://tourkit.dev/og-images/server-components-client-side-tours-boundary-problem.png",
  "url": "https://tourkit.dev/blog/server-components-client-side-tours-boundary-problem",
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://tourkit.dev/blog/server-components-client-side-tours-boundary-problem"
  },
  "keywords": ["react server components client tour", "rsc product tour", "server component onboarding boundary", "use client product tour"],
  "proficiencyLevel": "Intermediate",
  "dependencies": "React 18+, TypeScript 5+, Next.js 14+ (optional)",
  "programmingLanguage": {
    "@type": "ComputerLanguage",
    "name": "TypeScript"
  }
}
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "Can product tours work with React Server Components?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Tour Kit and other React tour libraries work with Server Components by isolating tour logic inside 'use client' boundaries. The tour provider, overlays, and interactive elements must be Client Components because they require browser APIs like useState, DOM measurement, and event handlers. Server Components in the same app stay server-rendered."
      }
    },
    {
      "@type": "Question",
      "name": "Why does 'use client' affect my tour library's bundle size?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "The 'use client' directive marks a module boundary, not just a single component. Every file imported by a 'use client' module becomes client code. If your tour provider imports heavy dependencies, all of those ship to the browser. Tour Kit keeps its provider imports minimal at under 8KB gzipped."
      }
    },
    {
      "@type": "Question",
      "name": "How do I avoid hydration mismatches with product tours?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Tour overlays that render based on client-only state create hydration mismatches because the server can't predict the initial render. Wrap tour UI in a useEffect mount gate: render nothing on the server, then show the tour after the component mounts on the client. Tour Kit handles this internally."
      }
    },
    {
      "@type": "Question",
      "name": "Should I define tour steps in Server Components or Client Components?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Define tour step configuration in Server Components and pass the data as props to your client-side tour provider. Step definitions are plain serializable objects that transfer through the RSC wire format without adding to your client bundle. Callbacks must be defined in Client Components."
      }
    },
    {
      "@type": "Question",
      "name": "Will the boundary problem go away in future React versions?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "The server-client boundary is a fundamental architectural decision in React, not a temporary limitation. React's core team has been clear that Server Components and Client Components will coexist. The tooling will improve, but the underlying constraint that interactive code runs on the client won't change."
      }
    }
  ]
}

Ready to try userTourKit?

$ pnpm add @tour-kit/react