Skip to main content
WebMCP relies on browser APIs (navigator.modelContext) that don’t exist on the server. TanStack Start renders components on both server and client during SSR, so you must ensure WebMCP code only runs in the browser.

The challenge

Unlike Next.js which has explicit 'use client' directives, TanStack Start components run during both SSR and client-side hydration by default. This means:
  • @mcp-b/global polyfill cannot be imported at module level
  • useWebMCP hooks will fail during SSR if not guarded
  • You need explicit client-only patterns

Client-only tool registration

Wrap your WebMCP tools in a client-only component using dynamic imports:
import { createFileRoute } from '@tanstack/react-router';
import { lazy, Suspense } from 'react';

// Lazy load the tools component - only runs on client
const HomeTools = lazy(() => import('../components/home-tools'));

export const Route = createFileRoute('/')({
  component: Home,
});

function Home() {
  return (
    <div>
      <h1>Home</h1>
      <Suspense fallback={null}>
        <HomeTools />
      </Suspense>
    </div>
  );
}
import '@mcp-b/global'; // Safe - only imported when this module loads on client
import { useWebMCP } from '@mcp-b/react-webmcp';
import { z } from 'zod';

export default function HomeTools() {
  useWebMCP({
    name: 'greet',
    description: 'Greet a user',
    inputSchema: { name: z.string() },
    handler: async ({ name }) => `Hello, ${name}!`,
  });

  return null; // Tools component renders nothing
}
Don’t import @mcp-b/global in route files directly. Route files are bundled for SSR and will fail when the polyfill tries to access navigator. Always import it in lazily-loaded client components.

Alternative: Environment check

If you prefer keeping tools in the same file, guard with an environment check:
import { createFileRoute } from '@tanstack/react-router';
import { useEffect, useState } from 'react';

export const Route = createFileRoute('/')({
  component: Home,
});

function Home() {
  const [isClient, setIsClient] = useState(false);

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

  return (
    <div>
      <h1>Home</h1>
      {isClient && <HomeTools />}
    </div>
  );
}

// Separate component that only mounts on client
function HomeTools() {
  // Dynamic import ensures this only loads on client
  const [ready, setReady] = useState(false);

  useEffect(() => {
    import('@mcp-b/global').then(() => {
      setReady(true);
    });
  }, []);

  if (!ready) return null;

  return <ToolsInner />;
}

function ToolsInner() {
  const { useWebMCP } = require('@mcp-b/react-webmcp');
  const { z } = require('zod');

  useWebMCP({
    name: 'greet',
    description: 'Greet a user',
    inputSchema: { name: z.string() },
    handler: async ({ name }) => `Hello, ${name}!`,
  });

  return null;
}
The lazy import pattern (first example) is cleaner and recommended. The environment check pattern is useful when you can’t easily split into separate files.

Tools that persist across navigation

For tools that should remain registered across route changes, place them in __root.tsx using the same client-only pattern:
import { createRootRoute, Outlet } from '@tanstack/react-router';
import { lazy, Suspense } from 'react';

const GlobalTools = lazy(() => import('../components/global-tools'));

export const Route = createRootRoute({
  component: Root,
});

function Root() {
  return (
    <>
      <Suspense fallback={null}>
        <GlobalTools />
      </Suspense>
      <Outlet />
    </>
  );
}

Common errors

Cause: Tools are registered in page components that unmount on navigationSolution: Move persistent tools to __root.tsx using the client-only pattern above.
Cause: Server HTML doesn’t match client HTML because tools render differentlySolution: Ensure tool components return null and use Suspense with fallback={null} for consistent server/client output.

Next steps

For complex patterns (nested layouts, context providers, embedded agents), see the Next.js guide which covers React SSR patterns in depth—the concepts apply to TanStack Start as well.