Now with AI-powered page building via MCP Server

Routes and pages

Three request shapes reach your app and need three different things. This is the wiring that serves all three.

Copy this whole. The pieces depend on each other, and the dependencies are not obvious.

The mental model

Three request shapes reach your app, and each needs something different:

  • A visitor - published content, server-rendered, static where possible. Speed, and the CMS staying out of the render path.
  • Draft preview (the /api/draft cookie) - draft content on the public route, no editor. Someone reviewing a change, not editing it.
  • The editor iframe (cmssyEdit=1 plus a matching cmssySecret) - draft content plus the edit bridge, on its own dynamic route.

That third row is the one that surprises people. A static page never sees the query string, so it cannot know it is being edited. That is why /cmssy-edit exists, and everything below follows from it.

1. Config

// cmssy.config.ts
import { defineCmssyConfig } from "@cmssy/next";

export const cmssy = defineCmssyConfig({
  org: process.env.CMSSY_ORG_SLUG,
  workspaceSlug: process.env.CMSSY_WORKSPACE_SLUG,
  draftSecret: process.env.CMSSY_DRAFT_SECRET,
});

Pass process.env raw. A ?? "" fallback turns a missing variable into an empty one, and the error then surfaces later, somewhere unrelated.

This module reads server env. Never import a value from it - or from a module that imports it - in a "use client" component. Types are fine, they are erased. Values drag process.env into the browser bundle.

2. Middleware

// proxy.ts
import { createCmssyProxy } from "@cmssy/next/middleware";
import { cmssy } from "@/cmssy.config";

export const proxy = createCmssyProxy(cmssy, {
  // Only if your URLs carry the language (/pl/about) AND your routes are
  // static paths rather than a catch-all.
  stripLocalePrefix: true,
});

// Next parses this at compile time, so the matcher must be a literal -
// an imported constant is rejected.
export const config = { matcher: ["/((?!_next/|api/|.*\\..*).*)"] };

The preset resolves the language, sends verified editor traffic to /cmssy-edit carrying that language and the edit flag, applies the CSP that lets the admin frame your site, and strips a language prefix if you asked - in that order.

The order is not a detail, and each way of getting it wrong has already shipped once:

  • Resolve the locale after the rewrite and the editor preview renders in the wrong language - a route cannot read a prefix it never sees.
  • Forget the edit flag and the header and footer arrive as markup the editor can select but not fill.

3. The public page

One catch-all route renders every published page. Metadata comes from a helper you own, built on the delivery API - the SDK hands you data, your app owns its routes:

// app/[[...path]]/page.tsx
import { createCmssyPage } from "@cmssy/next/server";
import { cmssy } from "@/cmssy/config";
import { blocks } from "@/cmssy/blocks";
import { buildPageMetadata } from "@/services/seo";

export const revalidate = 3600;
export const dynamicParams = true;

export async function generateMetadata({ params }) {
  const { path } = await params;
  // As routed, prefix and all: the prefix IS the language.
  return buildPageMetadata(path);
}

export default createCmssyPage(cmssy, blocks);

buildPageMetadata is yours, not the SDK's. It queries the page's SEO fields and returns a Next.js Metadata object - see SEO for what it contains.

4. The edit route

// app/cmssy-edit/[[...path]]/page.tsx
import { createCmssyEditPage } from "@cmssy/next/server";
import { cmssy } from "@/cmssy.config";
import { blocks } from "@/cmssy/blocks";
import { CmssyEditor } from "@/cmssy/editor";

export const dynamic = "force-dynamic";

export default createCmssyEditPage(cmssy, blocks, { editor: CmssyEditor });

Skip this file and the editor preview is blank. It is the single most common way to break a cmssy app, and nothing in a build will warn you - see testing.

5. The editor bridge

// cmssy/editor.tsx
"use client";
import { CmssyLazyEditor } from "@cmssy/react/client";
import type { CmssyEditorProps } from "@cmssy/next";

export function CmssyEditor(props: CmssyEditorProps) {
  return <CmssyLazyEditor {...props} load={() => import("./blocks")} />;
}

The registry is loaded lazily on the client, so your block loaders - which run server-side and read the config - never reach the browser bundle.

Caching

Publishing does not deploy. The public route caches according to its own revalidate, and cmssy can call a revalidation webhook on publish to invalidate sooner:

export const revalidate = 3600;
export const dynamicParams = true;

The edit route is the exception: it is force-dynamic on purpose, because a cached edit page would show yesterday's draft.

Next steps

  • Layouts - header and footer as editable blocks.
  • Testing - proving the edit route still works.
  • How cmssy works - what happens between request and render.