Skip to content

Experimental · Unofficial

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

Remix / React Router v7

Works with the /react adapter. The one wrinkle is env vars: Remix has no build-time client prefix like VITE_ or NEXT_PUBLIC_, so direct mode needs the values passed through the root loader.

Direct mode

Expose the public config from the root loader:

tsx
// app/root.tsx
import { useLoaderData, Scripts } from "react-router";

export function loader() {
  return {
    contentstack: {
      apiKey: process.env.CONTENTSTACK_API_KEY!,
      // Read-only delivery token. Shipping it to the client is the whole
      // trade-off of direct mode — see /guide/execution-modes.
      deliveryToken: process.env.CONTENTSTACK_DELIVERY_TOKEN!,
      environment: process.env.CONTENTSTACK_ENVIRONMENT!,
    },
  };
}

export default function Root() {
  const { contentstack } = useLoaderData<typeof loader>();

  return (
    <html lang="en">
      <body>
        <WebMcp config={contentstack} />
        <Outlet />
        <Scripts />
      </body>
    </html>
  );
}

Then build the stack on the client:

tsx
// app/components/WebMcp.tsx
import { useMemo, useCallback } from "react";
import { useNavigate } from "react-router";
import contentstack from "@contentstack/delivery-sdk";
import { useContentstackWebMcp } from "@timbenniks/contentstack-webmcp/react";
import { contentTypes } from "~/content-types";

type Config = { apiKey: string; deliveryToken: string; environment: string };

export function WebMcp({ config }: { config: Config }) {
  const navigate = useNavigate();
  const go = useCallback((path: string) => navigate(path), [navigate]);
  const stack = useMemo(() => contentstack.stack(config), [config]);

  useContentstackWebMcp({ mode: "direct", stack, contentTypes, navigate: go });

  return null;
}

WARNING

Anything returned from a loader is serialised into the HTML. A read-only, environment-scoped delivery token is the intended trade-off in direct mode — but never put a management token in a loader response.

Proxy mode

Cleaner in Remix, because you already have a server. No token reaches the client at all:

typescript
// app/routes/api.contentstack.$.ts
import type { LoaderFunctionArgs } from "react-router";
import contentstack from "@contentstack/delivery-sdk";
import { createContentstackWebMcpProxyHandler } from "@timbenniks/contentstack-webmcp/server";
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!,
});

const handler = createContentstackWebMcpProxyHandler({
  stack,
  contentTypes,
  basePath: "/api/contentstack",
});

export const loader = ({ request }: LoaderFunctionArgs) => handler(request);

The splat route api.contentstack.$.ts matches /api/contentstack/*, which is what proxyBasePath expects. Then the component needs no loader data:

tsx
useContentstackWebMcp({
  mode: "proxy",
  proxyBasePath: "/api/contentstack",
  contentTypes,
  navigate: go,
});

Permissions-Policy

typescript
// entry.server.tsx — add before returning the response
responseHeaders.set("Permissions-Policy", "tools=(self)");

Or set it at your host (Vercel, Netlify, Fly) — see Server runtimes.

React Router v7 (framework mode)

Identical to the above; the imports come from react-router rather than @remix-run/*. In library (SPA) mode there is no loader, so use the Vite env vars and follow React instead.

Released under the MIT License.