WebMCP in Next.js: A Step-by-Step Implementation Guide
Next.js powers millions of production apps. Almost none of them are discoverable by AI agents.
WebMCP, the W3C browser standard that lets AI agents call your site's functions directly, has solid React support. Next.js with the App Router is probably the easiest framework to start with because of how it handles client components.
I spent a weekend wiring WebMCP into a Next.js e-commerce project. This guide covers what I did: installing the packages, setting up the provider, registering tools with Zod validation, and testing everything in Chrome Canary. If your app is already running, you can get through it in under an hour.
Prerequisites and setup
What you need before starting
You don't need a complex stack for this.
Next.js 14+ with App Router. The Pages Router works too (I'll cover that in the FAQ), but the App Router makes the setup cleaner.
Chrome 146 Canary with the WebMCP flag enabled. This is the only browser that supports WebMCP natively right now. Go to chrome://flags, search for "WebMCP," and flip the toggle. Restart the browser after.
Node.js 18+ and a package manager. npm, yarn, or pnpm all work.
No backend changes required. No new databases or third-party API keys either.
Installing the WebMCP packages
Open your terminal and run this:
npm install @mcp-b/react-webmcp @mcp-b/global
Two packages. @mcp-b/react-webmcp gives you React hooks (useWebMCPTool and useWebMCPContext) that handle tool registration, lifecycle cleanup, and Zod validation. This is the one you'll use in your components.
@mcp-b/global is the polyfill. It provides the navigator.modelContext API for browsers that don't support it natively yet. Even in Chrome Canary, loading the polyfill first keeps behavior consistent.
One thing to watch: check your peer dependencies. React 18.x works fine. React 19 compatibility hasn't been confirmed yet, so keep an eye on the changelogs.
Configuring WebMCP with the Next.js App Router
This is where most people hit their first problem. App Router components are server components by default. WebMCP needs the browser. That mismatch causes confusion.
The 'use client' requirement
WebMCP hooks rely on navigator.modelContext, a browser API. Server components don't have access to navigator. So any component that registers a WebMCP tool needs the 'use client' directive at the top.
The mistake I keep seeing: developers put 'use client' on their root layout. That works, but it kills server-side rendering for your entire app. Your streaming breaks and your Time to First Byte tanks.
Instead, create a dedicated client component for your WebMCP logic and import it into your layout. Keep the boundary tight.
// src/components/WebMCPProvider.tsx
'use client';
import '@mcp-b/global';
import { WebMCPProvider as Provider } from '@mcp-b/react-webmcp';
export default function WebMCPProvider({ children }: { children: React.ReactNode }) {
return <Provider>{children}</Provider>;
}
The import '@mcp-b/global' line loads the polyfill. It needs to run before any WebMCP hooks fire, so putting it at the top of your provider component is the safest place.
Setting up the provider in your layout
Now wrap your app with the provider. Here's how it fits into a typical App Router layout:
// src/app/layout.tsx
import WebMCPProvider from '@/components/WebMCPProvider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<WebMCPProvider>
{children}
</WebMCPProvider>
</body>
</html>
);
}
Notice that the layout stays as a server component. Only the WebMCPProvider is a client component. Your server rendering stays intact, and WebMCP tools get registered on the client side where the browser APIs live.
Registering your first WebMCP tool
Now for the actual tool. You're going to create something AI agents can discover and call, with input validation built in.
Defining the tool schema with Zod
Every WebMCP tool needs a name, a description, and a schema that defines what inputs it accepts. @mcp-b/react-webmcp uses Zod for schema validation, so you get type safety and runtime validation together.
Here's a product search tool:
// src/components/tools/ProductSearchTool.tsx
'use client';
import { useWebMCPTool } from '@mcp-b/react-webmcp';
import { z } from 'zod';
const searchSchema = z.object({
query: z.string().describe('Search term for product name or description'),
category: z.enum(['electronics', 'clothing', 'home', 'sports'])
.optional()
.describe('Filter results by product category'),
maxPrice: z.number()
.positive()
.optional()
.describe('Maximum price in USD'),
});
export default function ProductSearchTool() {
useWebMCPTool({
name: 'searchProducts',
description: 'Search the product catalog by keyword, category, or price range. Returns matching products with names, prices, and availability.',
schema: searchSchema,
execute: async (input) => {
const params = new URLSearchParams();
params.set('q', input.query);
if (input.category) params.set('category', input.category);
if (input.maxPrice) params.set('maxPrice', String(input.maxPrice));
const res = await fetch(`/api/products/search?${params}`);
const data = await res.json();
return data;
},
});
return null;
}
A couple of things to notice here.
The .describe() calls on each Zod field aren't just for documentation. AI agents read those descriptions to figure out what to pass. If your description says "Search term for product name or description," the agent knows to pass a natural language query, not a product ID.
The component returns null because it doesn't render anything visible. It only exists to register the tool. You'll see this pattern a lot: invisible components that wire up functionality for agents.
Implementing the execute function
The execute function is where your actual logic lives. It receives validated input (Zod already checked it), does the work, and returns a JSON-serializable result.
A few things I've learned the hard way:
Return structured data, not HTML or plain text. Agents work with JSON and they need clear property names.
If something goes wrong, throw an error with a useful message. The WebMCP runtime catches it and sends it back to the agent. Vague errors just mean the agent retries blindly.
Keep each tool focused on one job. A searchProducts tool searches. It doesn't also add to cart. If you need that, register a separate addToCart tool.
execute: async (input) => {
try {
const res = await fetch(`/api/products/search?q=${input.query}`);
if (!res.ok) throw new Error(`Search failed with status ${res.status}`);
const data = await res.json();
return {
results: data.products,
total: data.total,
query: input.query,
};
} catch (err) {
throw new Error(`Product search failed: ${err.message}`);
}
},
Multiple tools and dynamic registration
Once you've got one tool working, you'll want more. And this is where the Next.js component model actually helps.
Registering multiple tools per page
You have two options for multi-tool pages.
Option 1: Separate tool components. Create individual components like ProductSearchTool, AddToCartTool, and CheckInventoryTool. Then compose them in your page:
// src/app/shop/page.tsx
import ProductSearchTool from '@/components/tools/ProductSearchTool';
import AddToCartTool from '@/components/tools/AddToCartTool';
import CheckInventoryTool from '@/components/tools/CheckInventoryTool';
export default function ShopPage() {
return (
<main>
<ProductSearchTool />
<AddToCartTool />
<CheckInventoryTool />
{/* Your regular UI components */}
</main>
);
}
Option 2: App-wide tools. For tools that should be available on every page (like getBusinessInfo or navigateToPage), register them inside your WebMCPProvider. These stay active regardless of which route the user (or agent) is on.
The part that surprised me: page-specific tools are automatically cleaned up when the user navigates away. The useWebMCPTool hook handles deregistration on unmount. An agent visiting your /shop page sees the shopping tools. Navigate to /support, and those tools disappear. The support tools appear instead.
That's route-based tool registration. You get it without writing any cleanup code.
Dynamic tool registration based on user state
The more interesting pattern is conditional tools.
Say you have an e-commerce site where logged-in users can track orders, but guests can't. You don't want an agent trying to call trackOrder when nobody's authenticated:
'use client';
import { useWebMCPTool } from '@mcp-b/react-webmcp';
import { useAuth } from '@/hooks/useAuth';
import { z } from 'zod';
export default function OrderTrackingTool() {
const { user, isAuthenticated } = useAuth();
useWebMCPTool({
name: 'trackOrder',
description: 'Track the status of an existing order by order ID',
schema: z.object({
orderId: z.string().describe('The order ID to track'),
}),
execute: async ({ orderId }) => {
const res = await fetch(`/api/orders/${orderId}`, {
headers: { Authorization: `Bearer ${user.token}` },
});
return res.json();
},
enabled: isAuthenticated,
});
return null;
}
The enabled flag controls whether the tool is visible to agents. When the user isn't logged in, the tool doesn't exist. The agent only sees what it can actually use.
You can extend this for role-based access. Admin tools get one set, premium users get another. Anything that depends on user state can conditionally register.
Testing your WebMCP implementation
You've written the code. Now you need to know if it works.
Local testing with Chrome Canary
My testing workflow looks like this:
Step 1. Start your Next.js dev server. npm run dev as usual.
Step 2. Open your app in Chrome Canary. Make sure the WebMCP flag is still enabled (it sometimes resets after updates).
Step 3. Open DevTools and run this in the console:
const tools = await navigator.modelContext.getTools();
console.table(tools.map(t => ({ name: t.name, description: t.description })));
You should see a table listing every tool registered on the current page. If the table is empty, something went wrong with your provider setup or your component isn't mounting.
Step 4. Test a tool directly:
const result = await navigator.modelContext.callTool('searchProducts', {
query: 'wireless headphones',
category: 'electronics',
maxPrice: 100
});
console.log(JSON.stringify(result, null, 2));
JSON back means it works. A validation error means your Zod schema doesn't match what you passed. A network error points to the API endpoint.
Step 5. Navigate between pages and re-run getTools(). Verify that route-specific tools appear and disappear as expected. This confirms your cleanup is working.
Automated testing strategies
You can't rely on Chrome Canary in CI/CD. Two approaches that work:
The simplest option is unit testing your execute functions directly. They're just async functions. Extract them and test them like anything else:
import { searchProducts } from '@/lib/tool-handlers';
test('searchProducts returns results for valid query', async () => {
const result = await searchProducts({ query: 'headphones' });
expect(result.results).toBeDefined();
expect(result.results.length).toBeGreaterThan(0);
});
For integration tests, mock navigator.modelContext:
beforeEach(() => {
const registeredTools = new Map();
Object.defineProperty(navigator, 'modelContext', {
value: {
registerTool: (tool) => registeredTools.set(tool.name, tool),
getTools: () => Array.from(registeredTools.values()),
callTool: async (name, input) => {
const tool = registeredTools.get(name);
return tool.execute(input);
},
},
writable: true,
});
});
This lets you verify that your components register the right tools without needing Chrome Canary in your CI pipeline.
Where to go from here
That's the full setup. Your Next.js app can now register tools that AI agents call directly, instead of scraping your DOM.
Chrome DevRel benchmarks show 89% less token usage and 98% task accuracy with WebMCP compared to DOM scraping. If you're paying per token on the agent side, that's a real difference.
I'd start with one tool. Pick the most important action on your site, whether that's a search or a booking form, and register it. Test it in Chrome Canary. Once you see an agent call your function and get structured JSON back, you'll probably want to add more.
The spec is still in early preview, so expect some rough edges. But the plumbing is solid, and the React integration is already more polished than I expected it to be at this stage.
Frequently asked questions
Does WebMCP work with the Next.js Pages Router?
Yes, but the App Router is easier. With Pages Router, you load the polyfill manually in _app.tsx and handle cleanup yourself. You don't get the automatic unmount behavior. It works, just with more boilerplate.
Can I use WebMCP with server components?
No. WebMCP relies on navigator.modelContext, which doesn't exist on the server. Use 'use client' for any component that registers tools. The pattern in this guide, a thin client-side provider wrapping server-rendered content, keeps the boundary small.
Will my WebMCP tools work in production browsers today?
Only in Chrome 146 Canary behind a feature flag. For production, use progressive enhancement. Wrap your tool registration in a 'modelContext' in navigator check so non-supporting browsers don't throw errors. Regular users see your app as normal. Agents with WebMCP-capable browsers get the tools.
How many tools should I register per page?
No hard limit, but keep it practical. Match the tools to the page's purpose. A product page might have getProductDetails, checkAvailability, and addToCart. A support page might have searchKnowledge and submitTicket. I'd keep it under ten per page. More than that and agent tool selection gets noisy.
Do I need Zod for schema validation?
The @mcp-b/react-webmcp hooks are built around Zod, so it's the easiest path. You could use @mcp-b/global directly with raw JSON Schema definitions, but you'd lose type inference and have to validate inputs yourself. Zod adds about 13KB. Worth it.