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:
// 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:
// 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:
// 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:
useContentstackWebMcp({
mode: "proxy",
proxyBasePath: "/api/contentstack",
contentTypes,
navigate: go,
});Permissions-Policy
// 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.