Next.js
Next.js is the one framework with two adapters: /react for the client hook and /next for the App Router route handler. Both are optional.
Direct mode
No route handler, no server component. One client component, mounted in the root layout.
// app/webmcp.tsx
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import contentstack from "@contentstack/delivery-sdk";
import { useContentstackWebMcp } from "@timbenniks/contentstack-webmcp/react";
import { contentTypes } from "@/content-types";
const stack = contentstack.stack({
apiKey: process.env.NEXT_PUBLIC_CONTENTSTACK_API_KEY!,
deliveryToken: process.env.NEXT_PUBLIC_CONTENTSTACK_DELIVERY_TOKEN!,
environment: process.env.NEXT_PUBLIC_CONTENTSTACK_ENVIRONMENT!,
});
export function WebMcp() {
const router = useRouter();
const navigate = useCallback((path: string) => router.push(path), [router]);
useContentstackWebMcp({ mode: "direct", stack, contentTypes, navigate });
return null;
}// app/layout.tsx
import { WebMcp } from "./webmcp";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<WebMcp />
{children}
</body>
</html>
);
}Mounting in the root layout means it registers once and survives client-side navigation.
TIP
useCallback on navigate matters — the hook's effect depends on it, so an inline arrow would re-register every tool on every render.
Proxy mode
Add a catch-all route handler and change one config line. The delivery token never reaches the browser.
// app/api/contentstack/[...path]/route.ts
import contentstack from "@contentstack/delivery-sdk";
import { createContentstackWebMcpRouteHandlers } from "@timbenniks/contentstack-webmcp/next";
import { contentTypes } from "@/content-types";
const stack = contentstack.stack({
apiKey: process.env.CONTENTSTACK_API_KEY!,
deliveryToken: process.env.CONTENTSTACK_DELIVERY_TOKEN!,
environment: process.env.CONTENTSTACK_ENVIRONMENT!,
});
export const { GET } = createContentstackWebMcpRouteHandlers({
stack,
contentTypes,
basePath: "/api/contentstack",
customRoutes: {
"/featured": async (_req, { stack: cda }) => {
const result = await cda
.contentType("blogpost")
.entry()
.query()
.where("featured", "$eq", true)
.find();
return Response.json({ data: result.entries ?? [] });
},
},
});Then in the client component, drop the stack entirely:
useContentstackWebMcp({
mode: "proxy",
proxyBasePath: "/api/contentstack",
contentTypes,
navigate,
});The [...path] segment is what makes /api/contentstack/search, /api/contentstack/entries/by-url, and the rest resolve to one handler.
Custom basePath
createContentstackWebMcpRouteHandlers(config, { basePath: "/api/v2/contentstack" });The options argument overrides config.basePath. Keep it in sync with proxyBasePath on the client.
Pages Router
The /next adapter targets the App Router. For pages/, use the framework-agnostic handler:
// pages/api/contentstack/[...path].ts
import type { NextApiRequest, NextApiResponse } from "next";
import { createContentstackWebMcpProxyHandler } from "@timbenniks/contentstack-webmcp/server";
const handler = createContentstackWebMcpProxyHandler({ stack, contentTypes });
export default async function route(req: NextApiRequest, res: NextApiResponse) {
const url = `https://${req.headers.host}${req.url}`;
const response = await handler(new Request(url));
res.status(response.status);
response.headers.forEach((value, key) => res.setHeader(key, value));
res.send(Buffer.from(await response.arrayBuffer()));
}The client hook is unchanged — swap next/navigation for next/router.
Env vars
# .env.local
# Direct mode — the browser needs these, so NEXT_PUBLIC_ is required
NEXT_PUBLIC_CONTENTSTACK_API_KEY=...
NEXT_PUBLIC_CONTENTSTACK_DELIVERY_TOKEN=... # read-only
NEXT_PUBLIC_CONTENTSTACK_ENVIRONMENT=production
# Proxy mode — server only, never prefixed
CONTENTSTACK_API_KEY=...
CONTENTSTACK_DELIVERY_TOKEN=...
CONTENTSTACK_ENVIRONMENT=productionWARNING
NEXT_PUBLIC_ inlines the value into the client bundle. That is intended in direct mode and a mistake in proxy mode — see Credentials.
Permissions-Policy
// next.config.js
module.exports = {
async headers() {
return [
{
source: "/:path*",
headers: [{ key: "Permissions-Policy", value: "tools=(self)" }],
},
];
},
};