Skip to content

Experimental · Unofficial

Built by Tim Benniks. This is not an official Contentstack product and is not supported by Contentstack.

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.

tsx
// 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;
}
tsx
// 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.

typescript
// 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:

tsx
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

typescript
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:

typescript
// 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

bash
# .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=production

WARNING

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

javascript
// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: "/:path*",
        headers: [{ key: "Permissions-Policy", value: "tools=(self)" }],
      },
    ];
  },
};

Released under the MIT License.