Layout Development Guide
How to create custom layouts (header + footer chrome) for glokki using @glokki-cms/sdk and @glokki-cms/react.
This guide covers defining Zod schemas for header and footer slots, implementing React components, registering layouts with defineLayout(), wiring <PageLayoutFrame> into the public render, and testing layouts in the visual editor. For initial project setup, see the Integration Guide. For per-block development inside the page tree, see the Block Development Guide.
1. Layout Anatomy
Terminology — "chrome": the UI elements that wrap a page's content but aren't the content themselves — header, footer, navigation, cookie banner, persistent sidebars. The term comes from UI design (compare "browser chrome" — the address bar, tabs, and toolbar around the page you're actually viewing). In this guide, a layout is the chrome.
A layout is the chrome that wraps every page in a site — typically a header and a footer. In Editor CMS the layout is tenant-owned: the schema, the React components, and the registry all live in your code. The CMS owns only the data (header and footer payloads per (layoutKey, locale)).
Every layout consists of four parts:
- Header Zod schema — declares the editable header data shape
- Footer Zod schema — declares the editable footer data shape
- Header + Footer React components — render given parsed data
defineLayout()call — bundles schemas, components, and metadata into aLayoutDefinition
Unlike blocks, layouts do not appear in the Component Library. Instead, each page references a layoutKey (e.g., "default", "landing", "empty"). At render time the registry maps the key to a LayoutDefinition and the CMS-supplied data is parsed against the layout's Zod schemas.
┌──────────────────┐ defineLayout() ┌──────────────────────┐
│ Header Schema │ ─────────────────▶ │ LayoutDefinition │
│ Footer Schema │ │ { key, schemas, │
│ Header Component│ │ Header, Footer } │
│ Footer Component│ └──────────┬───────────┘
└──────────────────┘ │
createLayoutRegistry()
│
▼
┌──────────────────────┐
│ LayoutRegistry │
│ (iframe handshake + │
│ <PageLayoutFrame>) │
└──────────────────────┘
2. Step-by-Step: Create a Layout
This walkthrough creates a Landing layout — a minimal chrome with a logo and CTA.
File Structure
layouts/
├── default/
│ ├── index.ts ← defineLayout({...})
│ ├── header.tsx ← headerSchema + Header
│ └── footer.tsx ← footerSchema + Footer
├── landing/
│ ├── index.ts
│ ├── header.tsx
│ └── footer.tsx
├── empty/
│ └── index.ts ← schemas + components inline (no chrome)
└── registry.ts ← createLayoutRegistry([Default, Landing, Empty])
One folder per layout. Co-locate schema + component per slot so they live next to each other. Export the defineLayout() result from each index.ts.
3. Define the Header Schema
The header schema is a z.object() describing the editable data. Each top-level field becomes an editable control in the props panel when the user clicks the header in the editor preview.
// layouts/landing/header.tsx
import { z } from "zod"
export const headerSchema = z.object({
logoSrc: z.string().default("").describe("Logo URL"),
logoHref: z.string().default("/").describe("Logo link"),
ctaLabel: z.string().default("Get started").describe("CTA label"),
ctaHref: z.string().default("/signup").describe("CTA URL"),
})
The call to action is two top-level fields (ctaLabel, ctaHref), not one cta: z.object({ label, href }). That is deliberate — see Fields Must Be Flat below.
Required: Defaults Everywhere
Every field that isn't explicitly .optional() must have a .default(...). Two reasons:
- Initial render — when a page is published for the first time,
page_layouts.published_*isNULL.<PageLayoutFrame>callsheaderSchema.parse({})— without defaults, the parse throws and the page crashes. - Adding new array items — the form renderer creates new array entries with empty objects. Each nested required field without a default throws on the next parse.
// ❌ Throws on first render if data is null/empty
ctas: z.array(
z.object({
label: z.string(), // no default!
variant: z.enum(["primary", "secondary"]), // no default!
})
).max(2)
// ✅ Safe — defaults all the way down
ctas: z.array(
z.object({
label: z.string().default(""),
variant: z.enum(["primary", "secondary"]).default("primary"),
})
)
.max(2)
.default([])
Required: Fields Must Be Flat
Every field of a header or footer schema must be a scalar (z.string(), z.number(), z.boolean(), z.enum([...])) or a list (z.array(...), usually marked repeater or menu-tree). A plain nested object — cookieBanner: z.object({ enabled, message }) — is not supported as a field, at any level (including a field inside a repeater row).
The reason is how your schema reaches the CMS. The visual editor's props panel and the MCP tools never see your Zod objects — they see a serialized list of field descriptors, and the serializer has no "object" field type. Any field it doesn't recognise becomes a plain "text" field. A nested object therefore arrives as a single text field, and from then on:
- the props panel shows a text input where you expected a group of controls;
- the CMS validates the field as a string, so writing the object through the MCP tools is refused (
INVALID_FIELD_VALUE, expected string); - the object's sub-fields don't exist as far as the CMS is concerned — a path like
cookieBanner.messageormobileNav.sectionsfails withFIELD_PATH_NOT_FOUND, and a navigation menu nested inside such an object is invisible to the menu tools.
Model the same data with separate top-level fields instead, and use a repeater for anything that is a list:
// ❌ Nested object — reaches the CMS as one "text" field
cookieBanner: z
.object({
enabled: z.boolean().default(false),
message: z.string().default(""),
})
.default({ enabled: false, message: "" }),
// ✅ Flat — each value is its own editable, addressable field
cookieBannerEnabled: z.boolean().default(false).describe("Show cookie banner"),
cookieBannerMessage: z
.string()
.default("")
.meta({ fieldType: "richtext" })
.describe("Cookie banner message"),
// ✅ A list of structured items — rows are objects, which is fine inside an array
socialLinks: z
.array(
z.object({
label: z.string().default("").describe("Label"),
href: z.string().default("").describe("URL"),
})
)
.default([])
.meta({ fieldType: "repeater" })
.describe("Social links"),
A shared prefix (cookieBanner…) keeps related fields together in the props panel, which lists fields in schema order. If your component prefers an object, assemble it in the component: const cookieBanner = { enabled: data.cookieBannerEnabled, message: data.cookieBannerMessage }.
Only .default() and .optional() May Wrap a Field
The serializer looks through exactly two wrappers to find a field's real type: .default(...) and .optional(). .describe(), .meta() and validation checks (.min(), .max(), .regex(), .url()) are safe too. Any other wrapper hides the type, and the field arrives as "text" — even when the inner type is a scalar:
| Field | Arrives in the CMS as | Consequence |
|---|---|---|
z.boolean().default(false) | switch | ✅ toggle |
z.boolean().catch(false) | text, required | text input; a real true/false is refused as "expected string" |
z.number().nullable().optional() | text | numbers are refused as "expected string" |
z.string().nullable() | text, required | null is refused |
z.string().meta({ fieldType: "image" }).catch("") | text, required | the image picker is lost (the same happens to textarea, richtext, color) |
z.preprocess(fn, z.string()), z.string().transform(fn) | text | for a string this mostly works, but any fieldType override is lost |
z.array(...).catch([]) | text | the repeater / menu editor is lost entirely |
So avoid .catch(), .nullable() / .nullish(), .transform(), z.preprocess() and z.union() on fields. Use .default(...) to cover missing data and .optional() when "no value" is meaningful. If you need to tolerate malformed stored data, do it in the component (e.g. typeof data.title === "string" ? data.title : ""), not in the schema.
To check what the CMS will see, inspect the serialized descriptors in a unit test — every field should have the fieldType you intended:
import { serializeBlockSchema } from "@glokki-cms/sdk"
expect(
serializeBlockSchema(footerSchema).find(
(f) => f.name === "cookieBannerEnabled"
)?.fieldType
).toBe("switch")
Key Zod Methods (same as blocks)
See the Block Development Guide §3 for the complete .describe(), .meta(), .default(), .optional(), .min(), .max(), .url(), .regex() reference — the same methods apply to layout schemas.
Special Field Types for Layouts
| Zod Type | .meta({ fieldType }) | Editor Control |
|---|---|---|
z.array(linkObject) | "menu-tree" | Recursive tree editor (used for nav items, mobile drawers) |
z.array(z.object({...})) | "repeater" | Add/remove/reorder list (used for CTAs, footer columns) |
z.string() | "richtext" | Rich text editor (used for cookie banner message) |
Existing entries in menu-tree and repeater collections render collapsed on first load to keep the panel compact. Newly-added entries appear expanded so the user can fill them in immediately.
4. Define the Footer Schema
Same rules as the header — every field defaults, and every field is flat (lists go in a repeater). Example with link columns:
// layouts/landing/footer.tsx
import { z } from "zod"
export const footerSchema = z.object({
columns: z
.array(
z.object({
title: z.string().default("").describe("Column heading"),
links: z
.array(
z.object({
label: z.string().default("").describe("Label"),
href: z.string().default("").describe("URL"),
})
)
.default([])
.meta({ fieldType: "repeater" })
.describe("Links"),
})
)
.max(6)
.default([])
.meta({ fieldType: "repeater" })
.describe("Footer columns"),
copyright: z.string().default("").describe("Copyright line"),
})
5. Implement Header and Footer Components
Each component receives the schema's inferred type via the data prop. The CMS supplies parsed data — your component never calls .parse() itself.
// layouts/landing/header.tsx (continued)
export type HeaderData = z.infer<typeof headerSchema>
export function Header({ data }: { data: HeaderData }) {
const { logoSrc, logoHref, ctaLabel, ctaHref } = data
return (
<header className="border-b border-border bg-background">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-8 px-6 py-4">
<a href={logoHref} className="flex items-center gap-2 font-semibold">
{logoSrc ? (
<img src={logoSrc} alt="Site logo" className="h-8 w-auto" />
) : (
<span>Site</span>
)}
</a>
{ctaLabel && (
<a
href={ctaHref}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
>
{ctaLabel}
</a>
)}
</div>
</header>
)
}
Component Guidelines
- Gate optional UI on truthy data — e.g.,
{logoSrc ? <img /> : <span>Site</span>}. Defaults are empty strings, not absent fields; check before rendering visuals that depend on real values. - Server-component-safe — no
useState, nouseEffect, no client hooks. Layouts render during SSR and inside the editor iframe. - No data fetching — the CMS provides all data via the
dataprop. - Use Tailwind CSS — components inherit the tenant app's styles.
- Match the schema's collapse signals — when the schema says
.optional()for a field, render it conditionally; when it always defaults to an empty value, gate on a meaningful field (e.g.,cookieBannerMessage).
6. Register with defineLayout()
Bundle the schemas, components, and metadata into a LayoutDefinition:
// layouts/landing/index.ts
import { defineLayout } from "@glokki-cms/sdk"
import { Footer, footerSchema } from "./footer"
import { Header, headerSchema } from "./header"
export const Landing = defineLayout({
key: "landing",
label: "Landing",
headerSchema,
footerSchema,
Header,
Footer,
})
defineLayout() Config
| Field | Type | Required | Description |
|---|---|---|---|
key | string | Yes | Unique layout key. Must be "default" for one layout in the registry. |
label | string | No | Display name in the page settings layout picker. Falls back to key. |
headerSchema | z.ZodType | Yes | Zod schema for header data |
footerSchema | z.ZodType | Yes | Zod schema for footer data |
Header | ComponentType<{ data }> | Yes | React component rendering the header |
Footer | ComponentType<{ data }> | Yes | React component rendering the footer |
7. Build the Layout Registry
Pass every defineLayout() result to createLayoutRegistry([...]):
// layouts/registry.ts
import { createLayoutRegistry } from "@glokki-cms/sdk"
import { Default } from "./default"
import { Empty } from "./empty"
import { Landing } from "./landing"
export const layoutRegistry = createLayoutRegistry([Default, Landing, Empty])
Build-Time Constraints
createLayoutRegistry() throws at module-eval time (build error) if:
- No layout has
key: "default"—"default"is the fallback every page lands on when no explicit key is set, so its presence is non-negotiable - Two layouts share the same
key— duplicate keys would silently shadow each other - The array is empty — every tenant must have at least one layout
These checks fail fast in your build output — no need to wire them through tests.
Empty Layout (Pages Without Chrome)
For pages that should render bare (modals, embed targets, marketing landings without header/footer), define an Empty layout:
// layouts/empty/index.ts
import { defineLayout } from "@glokki-cms/sdk"
import { z } from "zod"
export const Empty = defineLayout({
key: "empty",
label: "Empty (no chrome)",
headerSchema: z.object({}).strict(),
footerSchema: z.object({}).strict(),
Header: () => null,
Footer: () => null,
})
z.object({}).strict() rejects any keys, so authors editing this layout see no editable fields. The Header: () => null + Footer: () => null means nothing renders around the page content.
8. Wire <PageLayoutFrame> into Public Render
Your tenant app's public page renderer reads the page's layoutKey and layout data from the delivery response and forwards them to <PageLayoutFrame>:
// app/[[...slug]]/page.tsx (Next.js App Router)
import { CMSPage, PageLayoutFrame } from "@glokki-cms/react"
import { notFound } from "next/navigation"
import { cms } from "@/lib/cms"
import { blockRegistry } from "@/blocks/registry"
import { layoutRegistry } from "@/layouts/registry"
export default async function Page({
params,
}: {
params: Promise<{ slug?: string[] }>
}) {
const slug = (await params).slug?.join("/") ?? ""
const page = await cms.pages.getBySlug(slug)
if (!page) notFound()
return (
<PageLayoutFrame
registry={layoutRegistry}
layoutKey={page.layout.key}
headerData={page.layout.header}
footerData={page.layout.footer}
>
<CMSPage componentTree={page.componentTree} registry={blockRegistry} />
</PageLayoutFrame>
)
}
<PageLayoutFrame> looks up layoutKey in the registry and renders <Header data={parsed} /> {children} <Footer data={parsed} />. If the layout is missing from the registry, it throws — a hard failure that surfaces in error.tsx so misconfiguration is visible.
Slim Root Layout
Move every piece of chrome out of app/layout.tsx (the Next.js RootLayout) — <PageLayoutFrame> owns it now:
// app/layout.tsx
import "./globals.css"
import type { ReactNode } from "react"
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body className="min-h-screen bg-background text-foreground antialiased">
{children}
</body>
</html>
)
}
Wire the Editor Shell
For the editor iframe to render the right layout chrome, pass layoutRegistry to <VisualEditorProvider>:
// blocks/editor-shell.tsx
"use client"
import { CMSPageEditor, VisualEditorProvider } from "@glokki-cms/react"
import { layoutRegistry } from "@/layouts/registry"
import { blockRegistry, registry } from "./registry"
export function EditorShell({ pageSlug }: { pageSlug: string }) {
return (
<VisualEditorProvider
registry={registry}
blockRegistry={blockRegistry}
layoutRegistry={layoutRegistry}
pageSlug={pageSlug}
>
<CMSPageEditor />
</VisualEditorProvider>
)
}
The v3 handshake serialises layoutRegistry and ships it to the CMS so the props panel and page settings picker know which layouts exist.
9. Advanced Patterns
Folding Navigation into the Header
Earlier versions of the CMS had a separate globals/navigation.ts concept. In the layout model, nav is just a field of the header schema:
const navItemSchema: z.ZodType<{
label: string
href: string
children?: unknown[]
}> = z.lazy(() =>
z.object({
label: z.string().default(""),
href: z.string().default(""),
children: z.array(navItemSchema).optional(),
})
)
export const headerSchema = z.object({
// …logo, ctas, etc…
items: z
.array(navItemSchema)
.default([])
.meta({ fieldType: "menu-tree", maxDepth: 2 })
.describe("Primary navigation"),
})
fieldType: "menu-tree" triggers the recursive tree editor with drag/drop + child nesting.
Folding Cookie Banner into the Footer
Same idea — the cookie banner becomes a group of flat fields in the footer schema, sharing a cookieBanner prefix (a nested cookieBanner: z.object(...) would reach the CMS as a single text field — see Fields Must Be Flat):
export const footerSchema = z.object({
// …columns, copyright, etc…
cookieBannerMessage: z
.string()
.default("")
.meta({ fieldType: "richtext" })
.describe("Cookie banner message"),
cookieBannerAcceptLabel: z
.string()
.default("Accept")
.describe("Accept button"),
cookieBannerRejectLabel: z
.string()
.default("Reject")
.describe("Reject button"),
cookieBannerPolicyHref: z.string().default("").describe("Privacy policy URL"),
})
Gate the render on the message being non-empty so the banner widget doesn't render for layouts where the author hasn't filled one in, and assemble the object your widget expects inside the component:
{
data.cookieBannerMessage && (
<CookieBannerWidget
data={{
message: data.cookieBannerMessage,
acceptLabel: data.cookieBannerAcceptLabel,
rejectLabel: data.cookieBannerRejectLabel,
policyHref: data.cookieBannerPolicyHref,
}}
/>
)
}
Multiple Layouts per Tenant
Author multiple layouts (e.g., default for content pages, landing for marketing, empty for embeds). Each page references one via pages.layout_key. The InfoPanel layout picker in the editor shows every key from the registry; hides the dropdown when only one layout is registered.
When the user swaps a page's layout via the picker, the iframe re-fetches the new layout's draft data and re-renders chrome immediately — no save required for preview.
Layout Data Lifecycle
Layout data is tenant-wide, scoped per (tenant, layoutKey, locale). Editing the default layout's header on page A changes how page B renders too — the draft is shared.
- Save is debounced (~500ms after last keystroke) and writes to
page_layouts.draft_*. - Publish any page → that page's layout draft promotes to
published_*and CDN cache for every page using the same(layoutKey, locale)is purged. - Discard draft in the props panel copies
published_*back intodraft_*, undoing in-progress edits.
10. Testing Layouts Locally
Run the CMS and Tenant App Together
Same setup as Block Development §9 — start both dev servers, set the frontend URL in CMS settings, open a page in the visual editor.
Verify Layout Registration
After the handshake completes:
- The page settings sidebar should show a Layout picker if you registered more than one layout
- Clicking the header or footer in the iframe preview should open a props panel with your schema's fields
- Editing a field should update the iframe within ~500ms (no full reload)
After Every Deploy: Open the Visual Editor Once
The CMS never reads your layout schemas from your app directly. It keeps a snapshot of your layout registry (and your block registry) and refreshes it only when someone opens a page in the visual editor — every successful editor handshake re-sends both registries. Deploying your app does not refresh it, and neither an MCP client nor an API call can trigger the refresh.
So after every deploy that adds, renames or changes a layout or a layout field, open any page in the visual editor once against the deployed app (the editor loads whatever your Frontend URL setting points at, so the snapshot reflects that deployment). Until you do, the CMS still works from the old schema:
- MCP layout tools don't know the new field —
set_layout_fieldon it fails withFIELD_PATH_NOT_FOUND, and a changed field is validated against its old type; - a tenant whose editor has never been opened has no snapshot at all, and the MCP layout tools refuse with
LAYOUT_REGISTRY_NOT_SYNCED.
Keeping @glokki-cms/react Up to Date
<PageLayoutFrame> ships in the @glokki-cms/react package. If a layout fix you need lives in the package, upgrade it with your package manager (e.g. npm install @glokki-cms/react@latest), restart the dev server, and hard-reload the tenant app (Cmd+Shift+R). Schema changes inside your own tenant code don't need anything special — the Next.js dev server picks them up via HMR (and the next editor handshake sends them to the CMS).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
ZodError: expected string, received undefined | Required field without .default() | Add .default(...) to the failing field |
ZodError: expected object, received string | A nested z.object() field — the CMS treats it as a text field and stored a string | Flatten it into separate top-level fields (see Fields Must Be Flat) |
| A field shows as a plain text input, or the MCP tools say "expected string" for a boolean/number | The field is a nested object or is wrapped in .catch() / .nullable() / z.preprocess() | Flatten it, and use only .default() / .optional() as wrappers |
A new field is missing in the props panel or MCP says FIELD_PATH_NOT_FOUND after a deploy | The CMS's schema snapshot predates the deploy | Open any page in the visual editor once |
| Page crashes on first publish | published_* is NULL and schema has no defaults | Same fix — add defaults |
| Iframe doesn't re-render after layout swap | Stale dev build or browser cache | Hard reload; if it persists, restart the dev server and upgrade @glokki-cms/react |
| Layout picker missing from page settings | Only one layout registered | Register a second defineLayout({...}) |
| "Layout 'foo' is not registered" banner | Page references a key that was removed from the registry | Re-add the layout, or switch the page to a registered key |
Unit Testing a Layout
// layouts/landing/index.test.tsx
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { Landing } from "./index"
describe("Landing layout", () => {
it("has key='landing'", () => {
expect(Landing.key).toBe("landing")
})
it("Header renders with valid data", () => {
const data = Landing.headerSchema.parse({
logoSrc: "https://example.com/logo.png",
logoHref: "/",
})
const { container } = render(<Landing.Header data={data} />)
expect(container.querySelector("img")?.getAttribute("src")).toBe(
"https://example.com/logo.png"
)
})
it("Footer renders with empty defaults", () => {
const data = Landing.footerSchema.parse({})
expect(() => render(<Landing.Footer data={data} />)).not.toThrow()
})
it("schema accepts an empty object via defaults", () => {
// Guards against the "ZodError on first render" bug
expect(() => Landing.headerSchema.parse({})).not.toThrow()
expect(() => Landing.footerSchema.parse({})).not.toThrow()
})
})
The last test is the most important: it proves your schema can handle the never-published state. Add it to every layout — it would have caught most production runtime errors.
11. Checklist
Before considering a layout complete:
- Header schema is a
z.object()and every field has.default(...)or.optional() - Footer schema is a
z.object()and every field has.default(...)or.optional() - Every field is flat — no nested
z.object()fields; lists userepeater/menu-tree - No field is wrapped in
.catch(),.nullable(),.transform()orz.preprocess()— only.default()/.optional() - Array fields use
.default([])so newly-added items don't crash parse - Each field has
.describe("Label")for the props panel - Header / Footer components gate optional visuals on truthy data
- Components are server-component-safe (no hooks, no data fetching)
-
defineLayout({ key, headerSchema, footerSchema, Header, Footer })exports correctly -
createLayoutRegistry([...])includes a layout withkey: "default" -
app/[[...slug]]/page.tsxwraps<CMSPage>in<PageLayoutFrame> -
app/layout.tsx(RootLayout) is slim — no chrome JSX -
<VisualEditorProvider>receiveslayoutRegistryfor handshake v3 - Unit test:
headerSchema.parse({})andfooterSchema.parse({})both succeed - After deploying, opened the visual editor once so the CMS snapshot picks up the new schema
Next Steps
- Block Development Guide — for blocks rendered inside the page tree
- Integration Guide — full project setup and visual editor wiring