---
title: "Layout Development Guide"
url: "https://glokki.com/docs/layout-development"
locale: "en"
updated: "2026-10-26T23:25:41.674Z"
---

# 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](/docs/integration). For per-block development inside the page tree, see the [Block Development Guide](/docs/block-development).

---

## Table of Contents

1. [Layout Anatomy](#1-layout-anatomy)
2. [Step-by-Step: Create a Layout](#2-step-by-step-create-a-layout)
3. [Define the Header Schema](#3-define-the-header-schema)
4. [Define the Footer Schema](#4-define-the-footer-schema)
5. [Implement Header and Footer Components](#5-implement-header-and-footer-components)
6. [Register with `defineLayout()`](#6-register-with-definelayout)
7. [Build the Layout Registry](#7-build-the-layout-registry)
8. [Wire `<PageLayoutFrame>` into Public Render](#8-wire-pagelayoutframe-into-public-render)
9. [Advanced Patterns](#9-advanced-patterns)
10. [Testing Layouts Locally](#10-testing-layouts-locally)
11. [Checklist](#11-checklist)

---

## 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:

1. **Header Zod schema** — declares the editable header data shape
2. **Footer Zod schema** — declares the editable footer data shape
3. **Header + Footer React components** — render given parsed data
4. **`defineLayout()` call** — bundles schemas, components, and metadata into a `LayoutDefinition`

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.

```ts
// 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](#required-fields-must-be-flat) below.

### Required: Defaults Everywhere

Every field that isn't explicitly `.optional()` **must** have a `.default(...)`. Two reasons:

1. **Initial render** — when a page is published for the first time, `page_layouts.published_*` is `NULL`. `<PageLayoutFrame>` calls `headerSchema.parse({})` — without defaults, the parse throws and the page crashes.
2. **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.

```ts
// ❌ 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.message` or `mobileNav.sections` fails with `FIELD_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:

```ts
// ❌ 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:

```ts
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](/docs/block-development#3-define-the-zod-schema) 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:

```ts
// 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.

```tsx
// 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`, no `useEffect`, no client hooks. Layouts render during SSR and inside the editor iframe.
- **No data fetching** — the CMS provides all data via the `data` prop.
- **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`:

```ts
// 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([...])`:

```ts
// 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:

```ts
// 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>`:

```tsx
// 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:

```tsx
// 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>`:

```tsx
// 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:

```ts
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](#required-fields-must-be-flat)):

```ts
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:

```tsx
{
  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 into `draft_*`, undoing in-progress edits.

---

## 10. Testing Layouts Locally

### Run the CMS and Tenant App Together

Same setup as [Block Development §9](/docs/block-development#9-testing-blocks-locally) — 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:

1. The page settings sidebar should show a **Layout** picker if you registered more than one layout
2. Clicking the header or footer in the iframe preview should open a props panel with your schema's fields
3. 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_field` on it fails with `FIELD_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](#required-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

```ts
// 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 use `repeater` / `menu-tree`
- [ ] No field is wrapped in `.catch()`, `.nullable()`, `.transform()` or `z.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 with `key: "default"`
- [ ] `app/[[...slug]]/page.tsx` wraps `<CMSPage>` in `<PageLayoutFrame>`
- [ ] `app/layout.tsx` (RootLayout) is slim — no chrome JSX
- [ ] `<VisualEditorProvider>` receives `layoutRegistry` for handshake v3
- [ ] Unit test: `headerSchema.parse({})` and `footerSchema.parse({})` both succeed
- [ ] After deploying, opened the visual editor once so the CMS snapshot picks up the new schema

---

## Next Steps

- [Block Development Guide](/docs/block-development) — for blocks rendered inside the page tree
- [Integration Guide](/docs/integration) — full project setup and visual editor wiring
