The Quick Answer
Install webmcp-react, create a "use client" component for your tools, and import it into your server component pages:
$npm install webmcp-react zod
// app/components/tools.tsx
"use client";
import { WebMCPProvider, useMcpTool } from "webmcp-react";
import { z } from "zod";
function SearchTool() {
useMcpTool({
name: "search-products",
description: "Search the product catalog",
input: z.object({ query: z.string().describe("Search query") }),
handler: async ({ query }) => {
const results = await fetch(`/api/search?q=${query}`).then(r => r.json());
return { content: [{ type: "text", text: JSON.stringify(results) }] };
},
});
return null;
}
export function Tools() {
return (
<WebMCPProvider name="my-store" version="1.0.0">
<SearchTool />
</WebMCPProvider>
);
}Then import the client component into your server component page:
// app/page.tsx (server component — no "use client")
import { Tools } from "./components/tools";
export default function Home() {
return <main><h1>My Store</h1><Tools /></main>;
}The key pattern is pushing the "use client" boundary down to the tool components, not the page or layout. This preserves server component benefits (metadata exports, streaming, reduced client JS) while letting webmcp-react run in the browser after hydration.
Prerequisites
- Next.js 14+ with App Router (examples use Next.js 15)
- React 18+
- TypeScript recommended (the library ships type definitions)
- Familiarity with webmcp-react basics (provider, hooks, Zod schemas)
Installation
$npm install webmcp-react zod
zod-to-json-schema is bundled as a dependency — no separate install needed. The library has peer dependencies on react >= 18, react-dom >= 18, and zod >= 3.
If you're using an older version of Next.js or encounter module resolution issues, add webmcp-react to transpilePackages in your Next.js config:
// next.config.ts
const nextConfig = {
transpilePackages: ["webmcp-react"],
};
export default nextConfig;This tells Next.js to compile the package through its bundler rather than treating it as a pre-built module. Next.js 15 with Turbopack handles ESM packages automatically in most cases, so try without this setting first and add it only if you see module resolution errors.
Understanding the Client Boundary
Next.js App Router splits your code into server components (default) and client components (marked with "use client"). WebMCP tools use browser APIs — navigator.modelContext, React hooks, event handlers — so they must live inside client components. The question is where to draw that boundary.
A common mistake is adding "use client" to your root layout or page. This works but forces the entire subtree into client rendering, which means you lose server component benefits like the metadata export, React Server Components streaming, and smaller client bundles.
The better approach is to isolate WebMCP tools in dedicated client component files and import them into server component pages. The server component renders static content and passes any server-fetched data as props. The client component handles tool registration after hydration.
Setting Up the Provider
The <WebMCPProvider> should wrap the tool components that need it. You have two options for where to place it.
Option 1: Per-Page Provider (Recommended)
Place the provider in each page's tool component file. This is the simplest approach when different pages register different tools:
// app/flights/tools.tsx
"use client";
import { WebMCPProvider, useMcpTool } from "webmcp-react";
import { z } from "zod";
function FlightSearchTool() {
useMcpTool({
name: "search-flights",
description: "Search for available flights between two airports",
input: z.object({
origin: z.string().describe("Departure airport code (e.g. SFO)"),
destination: z.string().describe("Arrival airport code (e.g. JFK)"),
date: z.string().describe("Travel date in YYYY-MM-DD format"),
}),
handler: async ({ origin, destination, date }) => {
const res = await fetch(`/api/flights?from=${origin}&to=${destination}&date=${date}`);
const flights = await res.json();
return { content: [{ type: "text", text: JSON.stringify(flights) }] };
},
});
return null;
}Wrap it in a provider and export for the page to import:
export function FlightTools() {
return (
<WebMCPProvider name="travel-app" version="1.0.0">
<FlightSearchTool />
</WebMCPProvider>
);
}// app/flights/page.tsx
import { FlightTools } from "./tools";
export const metadata = { title: "Flight Search" };
export default function FlightsPage() {
return (
<main>
<h1>Search Flights</h1>
<FlightTools />
{/* ... rest of server-rendered UI */}
</main>
);
}Option 2: Shared Provider in a Client Layout
If every page in a route segment needs tools, create a client layout wrapper:
// app/providers.tsx
"use client";
import { WebMCPProvider } from "webmcp-react";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<WebMCPProvider name="travel-app" version="1.0.0">
{children}
</WebMCPProvider>
);
}// app/layout.tsx (stays a server component)
import { Providers } from "./providers";
export const metadata = { title: "Travel App" };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}Multiple <WebMCPProvider> instances can coexist — the internal polyfill is reference-counted and only cleaned up when the last provider unmounts. So even if you use both patterns (a shared provider in the layout and per-page providers in tool components), they won't conflict.
Registering Tools in Client Components
Each tool gets its own client component. The useMcpTool hook registers the tool on mount and unregisters it on unmount, so tools naturally appear and disappear as the user navigates between App Router pages.
// app/bookings/tools.tsx
"use client";
import { useMcpTool } from "webmcp-react";
import { z } from "zod";
export function BookingTools() {
useMcpTool({
name: "create-booking",
description: "Book a flight for the user",
input: z.object({
flightId: z.string().describe("Flight ID to book"),
passengers: z.number().int().min(1).max(9).describe("Number of passengers"),
}),
handler: async ({ flightId, passengers }) => {
const res = await fetch("/api/bookings", {
method: "POST",
body: JSON.stringify({ flightId, passengers }),
});
const booking = await res.json();
return { content: [{ type: "text", text: `Booked: ${booking.confirmationCode}` }] };
},
});
// ... additional tools in the same component
return null;
}A single component can call useMcpTool multiple times to register several related tools. For example, this same BookingTools component could also register a cancellation tool:
useMcpTool({
name: "cancel-booking",
description: "Cancel an existing booking",
input: z.object({
bookingId: z.string().describe("Booking confirmation code"),
}),
handler: async ({ bookingId }) => {
await fetch(`/api/bookings/${bookingId}`, { method: "DELETE" });
return { content: [{ type: "text", text: `Cancelled booking ${bookingId}` }] };
},
});Returning null is fine when the component exists purely for tool registration. You can also return UI — for example, a status indicator that shows when a tool is executing.
With App Router's file-based routing, tools are scoped by route. Navigate to /bookings and the booking tools appear. Navigate away and they're gone. This matches how the UI works — an agent shouldn't be able to cancel a booking from the flights search page.
Passing Server Data to Tool Components
A common pattern in Next.js is fetching data on the server and passing it as props to client components. This works well with WebMCP tools — the server component fetches what's needed, and the client component uses it in the tool handler. This is one of the main advantages of the App Router integration over a plain React SPA: you can seed tool definitions with data from your database, CMS, or internal APIs without exposing those data sources to the client.
// app/dashboard/page.tsx
import { DashboardTools } from "./tools";
export default async function DashboardPage() {
const projects = await db.projects.findMany();
return (
<main>
<h1>Dashboard</h1>
<DashboardTools projectIds={projects.map(p => p.id)} />
{/* ... render projects */}
</main>
);
}// app/dashboard/tools.tsx
"use client";
import { useMcpTool } from "webmcp-react";
import { z } from "zod";
export function DashboardTools({ projectIds }: { projectIds: string[] }) {
useMcpTool({
name: "get-project-stats",
description: "Get statistics for a project on the dashboard",
input: z.object({
projectId: z.string().describe(`Project ID (one of: ${projectIds.join(", ")})`),
}),
handler: async ({ projectId }) => {
const res = await fetch(`/api/projects/${projectId}/stats`);
const stats = await res.json();
return { content: [{ type: "text", text: JSON.stringify(stats) }] };
},
});
return null;
}Server-fetched projectIds flow through props into the Zod description, giving the agent context about which values are valid. The data serializes across the server-client boundary as plain JSON — Next.js handles this automatically for serializable props.
Keep the data you pass to tool components serializable (strings, numbers, arrays, plain objects). Functions, class instances, and other non-serializable values can't cross the server-client boundary.
Checking WebMCP Availability
Use useWebMCPStatus to conditionally render UI based on whether WebMCP is ready:
// app/components/agent-badge.tsx
"use client";
import { useWebMCPStatus } from "webmcp-react";
export function AgentBadge() {
const { available } = useWebMCPStatus();
if (!available) return null;
return <span className="badge badge-green">AI tools active</span>;
}This hook must be used inside a <WebMCPProvider>. It returns { available: false } during SSR and flips to true after the client-side polyfill initializes. Since both server and client render null initially (the hook starts as false on both sides), there's no hydration mismatch.
Connecting to Desktop AI Clients
Tools registered via navigator.modelContext live inside the browser tab. Desktop AI clients like Claude Code and Cursor need a bridge to discover them. The webmcp-react package includes a Chrome extension and local MCP server for this purpose. See connecting WebMCP tools to Claude Code via the bridge extension for the full setup.
The short version:
$npx webmcp-server
Then configure your AI client. For Claude Code:
$claude mcp add --transport stdio webmcp-server -- npx webmcp-server
For Cursor, add to .cursor/mcp.json:
{
"mcpServers": {
"webmcp-server": {
"command": "npx",
"args": ["webmcp-server"]
}
}
}Install the Chrome extension from the webmcp-react GitHub releases, activate it on your Next.js app's tab, and tools will appear in your AI client.
Common Issues
metadata export stops working after adding WebMCP
This happens when you add "use client" to a page or layout file. The metadata and generateMetadata exports only work in server components. Move the "use client" directive into a separate file (like tools.tsx or providers.tsx) and import that component into your server component page. The examples throughout this guide follow this pattern.
Module resolution error for webmcp-react
If you see errors like Module not found: Can't resolve 'webmcp-react' or ESM-related parse errors, add the package to transpilePackages in next.config.ts:
const nextConfig = {
transpilePackages: ["webmcp-react"],
};This is more common with Next.js 14 and the Webpack bundler. Next.js 15 with Turbopack resolves ESM packages correctly in most cases.
Tools not available immediately after page load (and hydration mismatches)
WebMCP tools register in a useEffect after React hydration completes, so there's a brief window (typically under 100ms) between page load and tool availability. This is expected — during SSR and the initial client render, no tools exist. If your agent workflow depends on tools being present immediately, poll with useWebMCPStatus or add a small delay on the agent side.
The useMcpTool hook initializes with isExecuting: false, executionCount: 0, and lastResult: null on both server and client, so it won't cause hydration mismatches on its own. If you're seeing mismatches, check that you're not conditionally rendering based on useWebMCPStatus in a way that differs between server and client. The safe pattern is to start with the "unavailable" state and update only after mount.
Next Steps
This guide covered the Next.js App Router integration pattern. For a deeper dive into the useMcpTool hook API, Zod schemas, tool lifecycle, and the Chrome extension bridge, see adding WebMCP tools to a React app with webmcp-react. To understand the raw navigator.modelContext API underneath, see registering your first WebMCP tool. For building tools that ask the user for confirmation before performing actions, see building a WebMCP confirmation flow with requestUserInteraction.
Related Guides
Add WebMCP tools to a React app with webmcp-react
How to use the webmcp-react library to register WebMCP tools in React with Zod schemas and connect them to desktop clients.
Register your first WebMCP tool with navigator.modelContext
How to use navigator.modelContext to register tools that AI agents can call directly from your website.
Build declarative WebMCP tools with HTML form attributes
Turn existing HTML forms into MCP tools using declarative attributes — no JavaScript required.