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

The challenge

Remix components run during both SSR and client-side hydration by default. This means:
  • @mcp-b/global polyfill cannot be imported at module level in routes
  • useWebMCP hooks will fail during SSR if not guarded
  • You need explicit client-only patterns

Client-only tool registration

Use ClientOnly from remix-utils (recommended) or lazy imports to ensure tools only register on the client:
pnpm add remix-utils
import { ClientOnly } from 'remix-utils/client-only';

export default function Index() {
  return (
    <div>
      <h1>Home</h1>
      <ClientOnly fallback={null}>
        {() => <HomeTools />}
      </ClientOnly>
    </div>
  );
}

// Separate component ensures imports happen client-side
function HomeTools() {
  // These imports only execute on the client
  require('@mcp-b/global');
  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;
}

Using lazy imports

import { lazy, Suspense } from 'react';

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

export default function Index() {
  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 or inside ClientOnly.

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 { ClientOnly } from 'remix-utils/client-only';
import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from '@remix-run/react';

export default function App() {
  return (
    <html lang="en">
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        <ClientOnly fallback={null}>
          {() => <GlobalTools />}
        </ClientOnly>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

function GlobalTools() {
  require('@mcp-b/global');
  const { useWebMCP } = require('@mcp-b/react-webmcp');

  useWebMCP({
    name: 'navigate',
    description: 'Navigate to a page',
    inputSchema: { path: require('zod').z.string() },
    handler: async ({ path }) => {
      window.location.href = path;
      return { navigatedTo: path };
    },
  });

  return null;
}

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 ClientOnly 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 Remix as well.