⏺ webmcp-ai.dev

home / guides / guide

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

  1. A null-rendering "use client" component, mounted once in the root layout, registers tools on document.modelContext.
  2. Each tool's execute is a Server Action. The browser calls it as a same-origin POST with the visitor's cookies.
  3. The action re-validates the input, calls one existing data function, and returns a small, redacted result.
  4. 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.

PackageMaintainerWhat you getPick it when
use-webmcp-toolGoogle Chrome Labs (Apache-2.0)One useWebMCP hook, zero runtime deps, JSON Schema in, formatOutput and onError, no-op where unsupportedYou want the spec, nothing else. Vercel's storefront uses it.
usewebmcpWebMCP-org (MIT)Strict-core hook with execution state (isExecuting, lastResult), enabled, literal-schema TypeScript inference, Zod 4.2+ accepted as Standard JSON SchemaYou want typed inputs and UI state for free.
@mcp-b/react-webmcpWebMCP-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 agentsYou need the MCP-B extensions or want tools to work on browsers without native WebMCP.
Hand-rolled useEffectYouForty lines, shown belowYou 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

// 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 })));
// 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

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