Now with AI-powered page building via MCP Server

Layouts

Header and footer are layout blocks - inherited down the page tree and editable like any other block.

A header is not a component you hardcode into app/layout.tsx. In cmssy it is a layout block: the same kind of thing as a hero or a pricing table, stored in the CMS, edited in the page builder, and rendered by a component from your registry.

That is a design decision with a consequence. If the header were markup, an editor could not change a nav link without a deploy. Because it is a block, they can.

Positions and inheritance

Layout blocks live at named positions - header and footer. A page either owns its layout or inherits it from its parent, so setting the header once on your root page gives it to the whole tree.

Override it only where a section genuinely differs: a landing page with no navigation, a checkout with a stripped footer. Everything else inherits, which means one edit updates every page that did not opt out.

Two root layouts, not one

The public route and the edit route each have their own root layout. They fetch the same layout groups and render them through different components, because a server-rendered header cannot be edited and a client-mounted one cannot be static.

You fetch the groups yourself - one delivery query, returning the layout blocks for a page:

// services/layout.ts
export async function fetchChromeLayouts(
  pageSlug: string,
  previewSecret?: string,
): Promise<CmssyLayoutGroup[]> { /* PublicPageLayouts query */ }

The public layout

CmssyServerLayout from @cmssy/react renders the groups server-side. It needs the block registry, because it resolves types on the server:

// app/[[...path]]/layout.tsx
import { CmssyServerLayout } from "@cmssy/react";

const { isEnabled: draft } = await draftMode();
const [locales, groups] = await Promise.all([
  resolveSiteLocales(),
  fetchChromeLayouts("/", draft ? cmssy.draftSecret : undefined),
]);

const slot = (position: "header" | "footer") => (
  <CmssyServerLayout
    groups={groups}
    blocks={blocks}
    position={position}
    locale={locale}
    defaultLocale={locales.defaultLocale}
    enabledLocales={locales.locales}
  />
);

The draft secret is passed only when draft mode is on. Pass it unconditionally and your public site starts serving unpublished header changes.

The edit layout

The edit route renders the same groups through your client wrapper instead:

// app/cmssy-edit/[[...path]]/layout.tsx
const slot = (position: "header" | "footer") => (
  <EditableLayout
    groups={groups}
    position={position}
    locale={locale}
    defaultLocale={locales.defaultLocale}
    enabledLocales={locales.locales}
    edit={{ editorOrigin }}
  />
);

Note what is missing: no blocks prop. The registry is loaded lazily on the client by the wrapper itself:

// cmssy/editable-layout.tsx
"use client";
import { CmssyLazyLayout, type CmssyLazyLayoutProps } from "@cmssy/react/client";

export function EditableLayout(props: Omit<CmssyLazyLayoutProps, "load">) {
  return <CmssyLazyLayout {...props} load={() => import("./blocks")} />;
}

Lazy loading here is not an optimisation, it is a boundary. Block modules can contain server loaders that read config and server-only dependencies; importing the registry eagerly from a client component would drag all of it into the browser bundle.

Both root layouts must set <html lang> from the resolved locale - the edit one included, because the preview has to declare the language it is actually rendering.

Why this is the part that breaks silently

Omit editable and everything still looks right. The site builds, visitors see the correct header, your tests pass.

But in the editor the header is now server-rendered markup. It is selectable and has no fields - the editor can highlight it and change nothing. There is no error, no warning, no red build.

This is exactly what checkCmssyEditMode asserts, and why its success condition reads backwards: no <header> in the server-rendered HTML of an edit-mode request is the passing state. See testing.

Next steps