WebMCP for Next.js & React
Implement WebMCP in a Next.js App Router app the way Vercel's own storefront does: a Client Component that registers tools, Server Actions as the backend, an origin-trial token from the root layout, and tests in Chrome and the ChatGPT browser.
By Ran Erez · Updated 2026-09-05
Next.js is the framework most likely to be running the site an agent lands on, and it has the cleanest WebMCP story of any stack: a Client Component registers the tools, Server Actions run them, and the App Router already gives you server-rendered HTML for the agents that read before they act. This guide follows the pattern Vercel used for its own Next.js storefront in August 2026 and adds the parts that pattern leaves out: origin-trial delivery, route-scoped tools, and testing. (Vercel's Shopify-backed template has since switched to Shopify's own storefront script; the standalone pattern lives on in the Vercel Commerce pull request, which was written to be copied.)
Two things changed since the first wave of tutorials. The API now lives on
document.modelContext (Chrome 150 deprecated the navigator alias), and
tools are unregistered with an AbortSignal instead of a removed
unregisterTool(). If a Next.js example you found uses either of the old forms, it
predates the current spec. And since late August, the
ChatGPT desktop browser executes WebMCP tools
without any token, so the audience for this work is no longer hypothetical.
"Next.js MCP" is not WebMCP
Searching for Next.js and WebMCP turns up the wrong thing first. Next.js 16 ships an
MCP server for coding agents
at /_next/mcp: it hands Claude Code or Cursor your build errors, routes and logs
while you develop. Vercel's mcp-handler package is different again, a way to host
a classic MCP server inside a route handler. Neither one does anything for a shopper using
Gemini in Chrome on your live site. That is WebMCP's job, and it is what the rest of this guide
is about. We compare all three in
Next.js MCP vs WebMCP.
The architecture in four lines
- A null-rendering
"use client"component, mounted once in the root layout, registers tools ondocument.modelContext. - Each tool's
executeis a Server Action. The browser calls it as a same-origin POST with the visitor's cookies. - The action re-validates the input, calls one existing data function, and returns a small, redacted result.
- The origin-trial token is rendered into
<head>by the layout so real Chrome users get the API, not just people with a flag.
Step 1: pick a hook (or none)
There is no official React binding; the spec is a W3C Community Group draft. Four options are worth considering, and the honest answer is that the first one is enough for most apps.
| Package | Maintainer | What you get | Pick it when |
|---|---|---|---|
use-webmcp-tool | Google Chrome Labs (Apache-2.0) | One useWebMCP hook, zero runtime deps, JSON Schema in, formatOutput and onError, no-op where unsupported | You want the spec, nothing else. Vercel's storefront uses it. |
usewebmcp | WebMCP-org (MIT) | Strict-core hook with execution state (isExecuting, lastResult), enabled, literal-schema TypeScript inference, Zod 4.2+ accepted as Standard JSON Schema | You want typed inputs and UI state for free. |
@mcp-b/react-webmcp | WebMCP-org (MIT) | Everything above plus MCP prompts, resources, and an MCP client for consuming tools; pairs with the @mcp-b/global polyfill for extension-based agents | You need the MCP-B extensions or want tools to work on browsers without native WebMCP. |
Hand-rolled useEffect | You | Forty lines, shown below | You dislike dependencies for a 4-method API. |
npm install use-webmcp-tool
# or, for the MCP-B ecosystem (prompts, resources, polyfill):
npm install @mcp-b/react-webmcp @mcp-b/global
Two older packages, next-webmcp and webmcp-next, wrap Next.js
specifically. Both were published in early 2026 against the navigator API and Zod 3,
and neither has moved since. Skip them.
Step 2: register tools in a Client Component
Keep the whole integration in one file so anyone can find every tool your site exposes. The
component renders nothing; it exists for its effects. Note the four details that the Vercel
reference gets right and most tutorials miss: additionalProperties: false on every
schema (ChatGPT requires it), the untrustedContentHint on tools that return
catalog text, error results converted into MCP isError, and Server Actions passed
straight in as execute.
// components/webmcp-tools.tsx
"use client";
import { useWebMCP } from "use-webmcp-tool";
import { searchProducts, addToCart } from "@/lib/webmcp/actions";
const READ_ONLY = { readOnlyHint: true, untrustedContentHint: true };
// Actions return { error: "..." } on failure. Turn that into a real MCP error,
// otherwise the hook serializes it as a *successful* result.
function reportErrors(result: unknown) {
if (result && typeof result === "object" && "error" in result) {
return { content: [{ type: "text", text: String(result.error) }], isError: true };
}
return result;
}
export function WebMCPTools() {
useWebMCP({
name: "shop.search_products",
description:
"Search this store's catalog. Pass a returned handle to shop.add_to_cart.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Words to search for in the catalog." },
limit: { type: "integer", description: "How many products to return. Default 5, max 10." }
},
required: ["query"],
additionalProperties: false
},
annotations: READ_ONLY,
execute: searchProducts,
formatOutput: reportErrors
});
useWebMCP({
name: "shop.add_to_cart",
description:
"Add one product variant to the cart. Search first, then pass the handle and quantity.",
inputSchema: {
type: "object",
properties: {
handle: { type: "string", description: "A product handle returned by shop.search_products." },
quantity: { type: "integer", description: "How many units to add. Defaults to 1." }
},
required: ["handle"],
additionalProperties: false
},
annotations: { readOnlyHint: false, consequentialHint: true },
execute: addToCart,
formatOutput: reportErrors
});
return null;
}
Mount it once, from the root layout, which stays a Server Component:
// app/layout.tsx (a Server Component; no "use client" here)
import { WebMCPTools } from "@/components/webmcp-tools";
const webmcpToken = process.env.WEBMCP_ORIGIN_TRIAL_TOKEN;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
{webmcpToken ? <meta httpEquiv="origin-trial" content={webmcpToken} /> : null}
</head>
<body>
{children}
<WebMCPTools />
</body>
</html>
);
}
If you would rather not add a dependency, the same thing without a hook is one effect and one
AbortController. The abort is what makes React StrictMode's double mount harmless
and what unregisters the tools on soft navigation away from the layout that owns them.
"use client";
import { useEffect } from "react";
export function WebMCPTools() {
useEffect(() => {
const mc = (document as any).modelContext;
if (typeof mc?.registerTool !== "function") return; // no agent runtime: no-op
// Aborting this signal unregisters every tool when the component unmounts.
// React StrictMode mounts twice in dev; the abort makes the second mount clean.
const controller = new AbortController();
const options = { signal: controller.signal };
Promise.all([
mc.registerTool({ name: "get_page_title", description: "Read the page title.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
annotations: { readOnlyHint: true },
execute: async () => ({ title: document.title }) }, options)
]).catch((err) => {
if (controller.signal.aborted) return; // AbortError on unmount is expected
console.error("WebMCP registration failed", err);
});
return () => controller.abort();
}, []);
return null;
}
Step 3: back tools with Server Actions
This is where Next.js beats every other stack. A "use server" function is already
a POST endpoint on your origin, already carries the visitor's session cookies, and is already
invisible to bots that only read HTML. Every action follows the same four steps: validate, call
one data function, shape a small result, return it.
// lib/webmcp/actions.ts
"use server";
import { z } from "zod";
import { searchIndexProducts } from "@/lib/shopify/products";
const SearchInput = z.strictObject({
query: z.string().trim().min(1).max(120),
limit: z.number().int().min(1).max(10).optional()
});
export async function searchProducts(input: unknown) {
const parsed = SearchInput.safeParse(input);
if (!parsed.success) return { error: "The input did not match the tool schema." };
try {
const result = await searchIndexProducts(parsed.data.query, parsed.data.limit ?? 5);
// Shape a small result. Agents pay for every token you return.
return {
products: result.map((p) => ({
handle: p.handle,
title: p.title.slice(0, 80),
price: p.price,
available: p.availableForSale
}))
};
} catch (err) {
console.error("WebMCP product search failed", err);
return { error: "Product search is unavailable right now." };
}
}
Three rules that are load-bearing. Validate again on the server: browsers do
not enforce required or types from your schema, and a confused or hostile agent
can send anything. Never accept an identifier you did not hand out: for
add-to-cart, derive the variant from the product handle server-side rather than accepting a
variant ID. Budget the output: Vercel caps each tool result at 1,450 characters
and paginates with cursors, because long results burn the agent's context and can trip
ChatGPT's configuration limits. The full pattern, including authorization, consequential
actions and route handlers as an alternative, is in
WebMCP tools backed by Next.js Server Actions.
Step 4: scope tools to routes and state
Chrome's guidance is that static registration should be the default and that you should
register extra tools only when a page state makes them useful. In the App Router that maps
onto component placement: a tool registered in app/layout.tsx exists everywhere,
a tool registered in a page or segment layout exists only while that route is mounted, and a
tool with enabled: false is unregistered the moment the condition flips.
// app/products/[handle]/product-tools.tsx
"use client";
import { useWebMCP } from "use-webmcp-tool";
export function ProductTools({ handle, inStock }: { handle: string; inStock: boolean }) {
useWebMCP({
name: "shop.add_this_product",
description: "Add the product on this page to the cart.",
inputSchema: { type: "object",
properties: { quantity: { type: "integer" } }, additionalProperties: false },
enabled: inStock, // unregisters the moment it stops being true
execute: async ({ quantity = 1 }) => addToCart({ handle, quantity })
});
return null;
}
Put authorization checks in the parent, not inside the tool component. If the admin tool
decides for itself whether it is enabled, it keeps its registration for a render after logout;
if the parent stops rendering it, the abort fires immediately. Keep the hook call unconditional
and pass the condition as enabled, which is the same rule as any other React hook.
Step 5: serve the origin-trial token
Without a token, document.modelContext is undefined for every ordinary Chrome
user (149 to 156, and Edge 150) and your tools are inert, no matter how well they work behind
the flag. The ChatGPT browser needs no token. Register the trial on
Chrome's origin trial page
for your production origin and deliver it one of two ways.
Option A: meta tag from the layout
The <meta httpEquiv="origin-trial"> line in the layout above is the whole
implementation. The layout is a Server Component, so process.env is read when the
page renders. One caveat: for routes that are fully static, "when the page renders" means at
build time, so the variable must exist in the build environment too. On Vercel it does.
Option B: header from proxy.ts
A response header reaches every route, including ones you forgot, and is read at request time
on self-hosted deployments where env vars only arrive at runtime. Do not use the
headers() array in next.config for this: it is evaluated once at
build and an empty token silently becomes an empty header.
// proxy.ts (Next.js 16; the file was called middleware.ts before 16.0)
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const res = NextResponse.next();
const token = process.env.WEBMCP_ORIGIN_TRIAL_TOKEN;
if (token) res.headers.set("Origin-Trial", token);
return res;
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"]
};
Two more headers matter. WebMCP requires origin isolation, so a stray
Origin-Agent-Cluster: ?0 disables it. And the API is gated by the
tools Permissions Policy, which defaults to self: if you register
tools inside a cross-origin iframe, the parent must set allow="tools" on the
frame. Note that ChatGPT's browser currently ignores tools registered in any iframe at all.
Step 6: test in Chrome, then in ChatGPT
- Local Chrome: enable
chrome://flags/#enable-webmcp-testing(and#devtools-webmcp-supportfor the DevTools pane) and restart. No token is needed on localhost. - DevTools: Application panel, WebMCP pane. It lists registered tools with their schemas, lets you invoke one with hand-written arguments, and logs every invocation. This is the fastest loop for iterating on descriptions.
- Console:
getTools()is the assertion you want in a smoke test.
// DevTools console, on your page, with the flag enabled:
const tools = await document.modelContext.getTools();
console.table(tools.map(t => ({ name: t.name, readOnly: t.annotations?.readOnlyHint })));
- Model Context Tool Inspector: Chrome's extension runs tools manually or with Gemini, so you see how a model actually interprets your descriptions. Do not browse untrusted sites with it enabled.
- ChatGPT desktop: open your site in the app's built-in browser. Registered tools appear under "Site tools" in the address bar. If the page says WebMCP is disabled because the configuration exceeds supported limits, you have too many tools or too much description text; trim before you debug anything else.
- CI: Playwright can launch real Chrome with the testing features and assert on
getTools(). Do not mockmodelContextin unit tests and call it done; the most common failure in production is a page whose tests pass and whose tools never register for a single real user.
// e2e/webmcp.spec.ts
import { test, expect, chromium } from "@playwright/test";
test("storefront registers its WebMCP tools", async () => {
const browser = await chromium.launch({
channel: "chrome",
args: ["--enable-features=WebMCPTesting,DevToolsWebMCPSupport"]
});
const page = await browser.newPage();
await page.goto("http://localhost:3000/");
const names = await page.evaluate(async () => {
const tools = await (document as any).modelContext.getTools();
return tools.map((t: any) => t.name).sort();
});
expect(names).toEqual(["shop.add_to_cart", "shop.search_products"]);
await browser.close();
});
Deploying on Vercel
- Set
WEBMCP_ORIGIN_TRIAL_TOKENas a Production environment variable. Tokens are bound to one origin, so a token forexample.comdoes nothing onexample-git-main-team.vercel.app. Preview deployments are tested with the flag or in ChatGPT. - Tokens expire, silently. Put the expiry date on a calendar; the trial's end is Chrome 156.
- Server Actions are POSTs to the page's route, so a
proxy.tsmatcher that excludes a path also skips proxy for the actions used on it. Check authorization inside each action, never only in proxy. - If you use BotID or another bot gate on cart or form endpoints, decide explicitly whether agent-initiated calls from a real user's browser should pass. They come from the user's session and are usually what you want.
Pages Router
Everything above works with pages/: mount the tools component in
_app.tsx, put the meta tag in _document.tsx, and use API routes as the
backend since Server Actions are App Router only. The one extra step is the same as in App
Router: never touch document outside an effect.
Frequently asked
Does Next.js support WebMCP?
Yes. WebMCP is a browser API, so any Next.js app can register tools from a Client Component. There is no framework switch to flip and nothing to configure in next.config. Vercel published a reference implementation for its Next.js storefront in August 2026.
Is the Next.js MCP server the same as WebMCP?
No. The Next.js 16 MCP server (next-devtools-mcp) gives coding agents dev-time access to errors, routes and logs. WebMCP lets browser agents such as Gemini in Chrome and ChatGPT call tools on your live pages. They solve different problems and do not overlap.
Can a WebMCP tool call a Server Action?
Yes, and it is the recommended backend. A "use server" function is a same-origin POST that carries the user's cookies, so the tool runs inside the visitor's real session. Validate the input again inside the action and never trust the schema alone.
Do WebMCP tools work with React Server Components?
Registration must happen in a Client Component because document.modelContext only exists in the browser. The rest of the page can stay server-rendered, which is what you want: agents read your HTML before they call your tools.
Does WebMCP work on Vercel preview deployments?
The tools register, but Chrome origin-trial tokens are bound to one origin, so a preview URL will not match your production token. Test previews with the chrome://flags/#enable-webmcp-testing flag or in the ChatGPT desktop browser, which needs no token.
Where this fits
WebMCP is the action layer. Agent readiness is mostly a reading problem: server-rendered content, JSON-LD in the HTML, an llms.txt, and sane AI-crawler rules. A Next.js app that renders client-side only scores near zero on content accessibility no matter how good its tools are. When something does not work, the Next.js WebMCP troubleshooting guide covers the twelve failures we see most. Check where you stand with the free scan, or map the exact tools your site should register on a free strategy call.
Sources
- Chrome: WebMCP imperative API and best practices
- OpenAI: WebMCP site tools in ChatGPT
- Vercel Commerce: Add WebMCP tools so AI agents can shop this storefront and the earlier vercel/shop reference implementation
- GoogleChromeLabs/use-webmcp-tool, WebMCP-org/npm-packages
- Next.js: proxy.ts file convention
- W3C WebMCP Community Group draft