---
title: "Block Development Guide"
url: "https://glokki.com/docs/block-development"
locale: "en"
updated: "2026-10-26T23:25:41.294Z"
---

# Block Development Guide

How to create custom blocks for glokki using @glokki-cms/sdk.

This guide covers defining Zod schemas, implementing React components, registering blocks with `defineBlock()`, and testing them in the visual editor. For initial project setup and integration, see the [Integration Guide](/docs/integration) first.

---

## Table of Contents

1. [Block Anatomy](#1-block-anatomy)
2. [Step-by-Step: Create a Block](#2-step-by-step-create-a-block)
3. [Define the Zod Schema](#3-define-the-zod-schema)
4. [Schema-to-Editor Field Mapping](#4-schema-to-editor-field-mapping)
5. [Implement the React Component](#5-implement-the-react-component)
6. [Register with `defineBlock()`](#6-register-with-defineblock)
7. [Add to the Block Registry](#7-add-to-the-block-registry)
8. [Advanced Patterns](#8-advanced-patterns)
9. [Testing Blocks Locally](#9-testing-blocks-locally)
10. [Checklist](#10-checklist)

---

## 1. Block Anatomy

Every block consists of three parts:

1. **Zod schema** — declares the editable props, their types, defaults, and labels
2. **React component** — renders the block given its props
3. **`defineBlock()` call** — bundles schema + component + metadata into a registry entry

The CMS never imports your Zod schema directly. Instead, `defineBlock()` serialises it into `FieldDescriptor[]` — a portable JSON format — and sends it to the CMS via the iframe handshake. The CMS builds its PropsPanel entirely from these descriptors.

```
┌──────────────────┐     defineBlock()     ┌────────────────────┐
│  Zod Schema      │ ──────────────────▶   │  FieldDescriptor[] │
│  + React Component                       │  + Component ref   │
└──────────────────┘                       └────────┬───────────┘
                                                    │
                                           createBlockRegistry()
                                                    │
                                                    ▼
                                           ┌────────────────────┐
                                           │  BlockRegistry      │
                                           │  (iframe handshake) │
                                           └────────────────────┘
```

---

## 2. Step-by-Step: Create a Block

This walkthrough creates a **Testimonial** block from scratch.

### File Structure

```
blocks/
├── testimonial.tsx    ← schema + component + defineBlock()
├── registry.ts        ← createBlockRegistry() with all blocks
└── editor-shell.tsx   ← VisualEditorProvider wrapper
```

One file per block, co-located with the registry. Export the `defineBlock()` result and optionally the component for standalone use.

---

## 3. Define the Zod Schema

The schema is a `z.object()` where each key becomes an editable field in the CMS PropsPanel. Zod methods control how the field appears:

```ts
import { z } from "zod"

const testimonialSchema = z.object({
  quote: z
    .string()
    .min(1)
    .max(500)
    .default("This product changed everything for us.")
    .meta({ fieldType: "textarea" })
    .describe("Quote"),

  author: z
    .string()
    .min(1)
    .max(100)
    .default("Jane Doe")
    .describe("Author Name"),

  role: z.string().max(100).optional().describe("Author Role"),

  avatar: z
    .string()
    .url()
    .optional()
    .meta({ fieldType: "image" })
    .describe("Avatar"),

  rating: z.number().min(1).max(5).default(5).describe("Rating"),

  highlighted: z.boolean().default(false).describe("Highlighted"),

  style: z.enum(["card", "minimal", "quote"]).default("card").describe("Style"),

  accentColor: z
    .string()
    .default("#3b82f6")
    .meta({ fieldType: "color" })
    .describe("Accent Color"),
})
```

### Key Zod Methods

| Method                          | Purpose                                             | Example                                        |
| ------------------------------- | --------------------------------------------------- | ---------------------------------------------- |
| `.describe("Label")`            | Sets the field label in the PropsPanel              | `.describe("Author Name")`                     |
| `.meta({ fieldType: "..." })`   | Overrides the default field control                 | `.meta({ fieldType: "image" })`                |
| `.meta({ placeholder: "..." })` | Sets placeholder text                               | `.meta({ placeholder: "Enter a quote..." })`   |
| `.meta({ helpText: "..." })`    | Adds help text below the control                    | `.meta({ helpText: "Shown below the quote" })` |
| `.meta({ hidden: true })`       | Hides the field from the editor                     | `.meta({ hidden: true })`                      |
| `.default(value)`               | Sets the default value and makes the field optional | `.default("Hello")`                            |
| `.optional()`                   | Makes the field optional (no default value)         | `.optional()`                                  |
| `.min(n)` / `.max(n)`           | String length or numeric range bounds               | `.min(1).max(500)`                             |
| `.url()`                        | Marks a string as a URL field                       | `.url()`                                       |
| `.regex(pattern)`               | Adds a regex validation constraint                  | `.regex(/^#[0-9a-f]{6}$/i)`                    |

### Rules

- The root schema **must** be `z.object()` — `z.record()`, `z.string()`, etc. are not supported.
- Every field **should** have `.describe("Label")` — without it, the label is auto-generated from the key name (e.g., `authorName` becomes "Author Name").
- Use `.default()` to declare the fallback value for schema validation and field metadata. Note: `.default()` alone does **not** set initial values when adding a block — that's what `defaultProps` in `defineBlock()` does. Always set both.
- Use `.optional()` for truly optional fields with no sensible default.
- Fields **must be flat**: scalars and arrays only, no nested `z.object()` field, and no wrapper other than `.default()` / `.optional()` — see [Fields Must Be Flat](#fields-must-be-flat).

---

## 4. Schema-to-Editor Field Mapping

The SDK serialises Zod schemas into `FieldDescriptor[]`. Each Zod type maps to a specific editor control:

| Zod Type                                                                                | Editor Control              | Notes                                                                        |
| --------------------------------------------------------------------------------------- | --------------------------- | ---------------------------------------------------------------------------- |
| `z.string()`                                                                            | Text input                  | Single-line text field                                                       |
| `z.string()` + `.meta({ fieldType: "textarea" })`                                       | Textarea                    | Multi-line text                                                              |
| `z.string()` + `.meta({ fieldType: "richtext" })`                                       | Rich text editor            | HTML/Markdown editor                                                         |
| `z.string().url()`                                                                      | URL input                   | With URL validation                                                          |
| `z.string()` + `.meta({ fieldType: "image" })`                                          | Image picker                | Upload or URL input                                                          |
| `z.string()` + `.meta({ fieldType: "color" })`                                          | Color picker                | Hex color selector                                                           |
| `z.number()`                                                                            | Number input                | With optional min/max                                                        |
| `z.boolean()`                                                                           | Toggle switch (`"switch"`)  | On/off toggle                                                                |
| `z.enum([...])`                                                                         | Select dropdown             | Options from enum values                                                     |
| `z.array(z.object({...}))`                                                              | Repeatable list (`"array"`) | Add/remove/reorder items (up/down buttons)                                   |
| `z.array(z.object({...}))` + `.meta({ fieldType: "repeater" })`                         | Repeater (`"repeater"`)     | Add/remove rows, drag-and-drop reorder                                       |
| anything else (nested `z.object()`, `z.union()`, a `.catch()`/`.nullable()` wrapper, …) | Text input (`"text"`)       | Almost never what you want — see [Fields Must Be Flat](#fields-must-be-flat) |

> **Rendering uploaded images:** any image field — by any name, scalar or
> array, nested at any depth — is rewritten by the Delivery API to a `/cdn/`
> URL with `?w&h&fp` query params. Render it with a small image component of
> your own that builds a responsive `srcSet` by re-stamping `?w` and applies
> `?fp` as CSS `object-position`. See [Asset CDN Proxy](/docs/asset-cdn-proxy#6-query-parameters).

### How Validation Maps

Zod validation methods are extracted and sent to the CMS for client-side validation:

```ts
// String validation
z.string().min(1).max(120)
// → { validation: { minLength: 1, maxLength: 120 } }

// Number validation
z.number().min(0).max(100)
// → { validation: { min: 0, max: 100 } }

// Regex validation
z.string().regex(/^[A-Z]/)
// → { validation: { pattern: "^[A-Z]" } }
```

### How Defaults and Optionality Map

```ts
// Required field (no default, not optional)
z.string().min(1).describe("Title")
// → { required: true }

// Optional with default
z.string().default("Hello").describe("Greeting")
// → { required: false, defaultValue: "Hello" }

// Optional without default
z.string().optional().describe("Subtitle")
// → { required: false }
```

### Fields Must Be Flat

Every field of a block schema must be a **scalar** (`z.string()`, `z.number()`, `z.boolean()`, `z.enum([...])`) or an **array** (`z.array(...)`, optionally marked `repeater`). A plain nested object — `cta: z.object({ label, href })` — is **not** supported as a field, at any level (a field inside an array row included).

The reason is the serialisation step above: the CMS never sees your Zod schema, only the `FieldDescriptor[]` built from it, and there is no "object" field type. Any field the serializer doesn't recognise becomes a plain `"text"` field. A nested object therefore arrives as a single text field: the PropsPanel shows a text input, the CMS validates the value as a **string** (so the publish check, `validate_page` and the MCP write tools reject the object you meant to store), and its sub-fields can't be addressed individually.

Split the object into top-level fields instead, and use an array of objects for anything that's a list:

```ts
// ❌ Reaches the CMS as one "text" field
cta: z.object({ label: z.string(), href: z.string() }).default({ label: "", href: "" }),

// ✅ Flat
ctaLabel: z.string().default("Learn more").describe("CTA label"),
ctaHref: z.string().default("/").describe("CTA link"),
```

**Wrappers.** The serializer looks through exactly two wrappers to find a field's type: `.default(...)` and `.optional()` (plus `.describe()`, `.meta()` and validation checks such as `.min()`, `.max()`, `.regex()`, `.url()`). 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; `true`/`false` are rejected as "expected string" |
| `z.number().nullable().optional()`                  | `text`                | numbers are rejected as "expected string"                    |
| `z.string().nullable()`                             | `text`, required      | `null` is rejected                                           |
| `z.string().meta({ fieldType: "image" }).catch("")` | `text`, required      | the image picker (or textarea, richtext, color) is lost      |
| `z.array(...).catch([])`                            | `text`                | the list editor is lost entirely                             |

So don't use `.catch()`, `.nullable()` / `.nullish()`, `.transform()`, `z.preprocess()` or `z.union()` on fields. Cover missing data with `.default(...)`, and tolerate unexpected values in the component rather than in the schema. A unit test on `YourBlock.fields` (see [Unit Testing Blocks](#unit-testing-blocks)) catches a field that serialises to the wrong `fieldType`.

---

## 5. Implement the React Component

The component receives the schema's inferred type as props. Use `z.infer<typeof schema>` for type safety:

```tsx
type TestimonialProps = z.infer<typeof testimonialSchema>

function TestimonialComponent({
  quote = "This product changed everything for us.",
  author = "Jane Doe",
  role,
  avatar,
  rating = 5,
  highlighted = false,
  style = "card",
  accentColor = "#3b82f6",
}: TestimonialProps) {
  return (
    <section
      className={`px-6 py-8 sm:px-8 sm:py-12 ${
        style === "card"
          ? "mx-auto max-w-lg rounded-xl border bg-card p-6 shadow-sm"
          : style === "quote"
            ? "border-l-4 pl-6"
            : ""
      } ${highlighted ? "outline outline-2 outline-offset-2" : ""}`}
      style={{
        ...(style === "quote" ? { borderColor: accentColor } : {}),
        ...(highlighted ? { outlineColor: accentColor } : {}),
      }}
    >
      <blockquote className="text-base leading-relaxed italic sm:text-lg">
        &ldquo;{quote}&rdquo;
      </blockquote>

      <div className="mt-4 flex items-center gap-3">
        {avatar && (
          <img
            src={avatar}
            alt={author}
            className="h-10 w-10 rounded-full object-cover"
          />
        )}
        <div>
          <p className="font-semibold">{author}</p>
          {role && <p className="text-sm text-muted-foreground">{role}</p>}
        </div>
      </div>

      {rating > 0 && (
        <div
          className="mt-3 flex gap-0.5"
          aria-label={`${rating} out of 5 stars`}
        >
          {Array.from({ length: 5 }, (_, i) => (
            <span
              key={i}
              className={`text-lg ${i < rating ? "opacity-100" : "opacity-20"}`}
              style={i < rating ? { color: accentColor } : undefined}
            >
              ★
            </span>
          ))}
        </div>
      )}
    </section>
  )
}
```

### Component Guidelines

- **Set default values in the function signature** matching `defaultProps` — the component may render before the CMS sends initial props.
- **Handle optional fields gracefully** — check for `undefined` before rendering optional elements.
- **Use Tailwind CSS** for styling — the CMS iframe inherits the tenant app's styles.
- **Keep components pure** — no side effects, no data fetching, no hooks beyond basic React state.
- **Avoid external state** — each block instance should be self-contained.

---

## 6. Register with `defineBlock()`

Bundle the schema, component, and metadata into a `BlockRegistryEntry`:

```ts
import { defineBlock } from "@glokki-cms/sdk"

export const TestimonialBlock = defineBlock({
  type: "testimonial",
  label: "Testimonial",
  category: "content",
  icon: "Quote",
  description: "Customer testimonial with rating and avatar",
  defaultProps: {
    quote: "This product changed everything for us.",
    author: "Jane Doe",
    rating: 5,
    highlighted: false,
    style: "card",
    accentColor: "#3b82f6",
  },
  propsSchema: testimonialSchema,
  component: TestimonialComponent,
})
```

### `defineBlock()` Config

| Field              | Type                                                   | Required | Description                                                                                                                                                                                                                     |
| ------------------ | ------------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`             | `string`                                               | Yes      | Unique block type key (e.g., `"testimonial"`)                                                                                                                                                                                   |
| `label`            | `string`                                               | Yes      | Display name in the Component Library                                                                                                                                                                                           |
| `category`         | `string`                                               | Yes      | Category key for grouping — free-form, 1–64 characters, no whitespace. Must appear in the registry's `categories` list when one is declared; otherwise use a CMS built-in (`content`, `layout`, `media`, `interactive`, `list`) |
| `icon`             | `string`                                               | Yes      | Lucide icon name (e.g., `"Quote"`, `"Image"`, `"Sparkles"`)                                                                                                                                                                     |
| `description`      | `string`                                               | No       | Short description shown in the Component Library                                                                                                                                                                                |
| `defaultProps`     | `InferOutput<TSchema>`                                 | Yes      | Default values when adding a new instance                                                                                                                                                                                       |
| `propsSchema`      | `z.object({...})`                                      | Yes      | Zod object schema                                                                                                                                                                                                               |
| `component`        | `ComponentType`                                        | Yes      | React component                                                                                                                                                                                                                 |
| `viewportDefaults` | `{ tablet?: Partial<Props>, mobile?: Partial<Props> }` | No       | Per-viewport prop overrides                                                                                                                                                                                                     |
| `slots`            | `SlotDefinition[] \| (props) => SlotDefinition[]`      | No       | Named areas for child blocks                                                                                                                                                                                                    |
| `summary`          | `{ field: string, noun: string }`                      | No       | Count summary shown on the editor's collapsed block card                                                                                                                                                                        |

### Block card summary

`summary` adds a short at-a-glance count to the block's card in the editor's
Blocks list — e.g. a slider showing "2 slides" instead of just "Slider". It
counts the length of an **array prop** and pluralises the noun:

```ts
export const SliderBlock = defineBlock({
  type: "slider",
  label: "Slider",
  // …
  propsSchema: z.object({ slides: z.array(slideSchema) }),
  defaultProps: { slides: [] },
  summary: { field: "slides", noun: "slide" }, // → "0 slides", "1 slide", "2 slides"
  component: Slider,
})
```

It must be a plain **serialisable object** (`{ field, noun }`), not a function —
block definitions cross the CMS handshake as JSON, so a callback could not be
sent. The CMS reads `props[field].length` and renders `N noun(s)`; nothing shows
when `summary` is omitted or the field isn't an array.

### `defaultProps` vs `.default()`

`defaultProps` sets the initial values when the block is added from the Component Library. Zod `.default()` values are used for schema validation and field metadata. **They should match** for consistency:

```ts
// In the schema:
heading: z.string().default("Welcome").describe("Heading"),

// In defineBlock():
defaultProps: {
  heading: "Welcome",  // ← matches the schema .default()
}
```

### Markdown for agents (optional)

`defineBlock()` also accepts `markdown: (props, ctx) => string | null` (or
`markdown: false`) to control how the block appears when the page is served as
Markdown to LLM agents. Without it the block falls back to its text fields.
See the [Markdown for agents guide](/docs/markdown-for-agents).

---

## 7. Add to the Block Registry

Import the new block into your registry file and add it to `createBlockRegistry()`:

```ts
// blocks/registry.ts
import { createBlockRegistry } from "@glokki-cms/sdk"
import type { ComponentType } from "react"

import { HeroBlock } from "./hero"
import { TestimonialBlock } from "./testimonial"

export const blockRegistry = createBlockRegistry([
  HeroBlock,
  TestimonialBlock,
  // ... other blocks
])

export const registry: Map<
  string,
  ComponentType<Record<string, unknown>>
> = new Map(
  blockRegistry
    .getTypes()
    .map((type) => [
      type,
      blockRegistry.getComponent(type) as ComponentType<
        Record<string, unknown>
      >,
    ])
)
```

The `blockRegistry` is passed to `<VisualEditorProvider>` for the v2 handshake. The `registry` Map is used by `<CMSPageEditor>` to resolve components by type.

`createBlockRegistry()` throws if duplicate `type` values are detected.

### Grouping blocks under your own categories

`category` is a free-form key, so the picker's groups are yours to name. Pass an
ordered `categories` list as `createBlockRegistry()`'s second argument:

```ts
export const blockRegistry = createBlockRegistry(
  [HeroBlock, TestimonialBlock],
  {
    categories: [
      { key: "parish", label: "Parish life" },
      { key: "content", label: "Content" },
    ],
  }
)
```

The declared list **replaces** the CMS's built-in categories (`content`,
`layout`, `media`, `interactive`, `list`) rather than merging with them, and the
picker renders them in the array's order. Keys and labels must be non-empty and
at most 64 characters, keys may not contain whitespace, and every block's
`category` must appear in the list — `createBlockRegistry()` throws at build
time otherwise. `blockRegistry.getCategories()` returns the declared list, or
`undefined` when you declared none; it reaches the CMS on the same handshake as
the block definitions, so it needs `@glokki-cms/react` 0.9.0 or later.

#### Order is an editorial decision, not just a sort

The picker opens categories from the top until it has revealed twelve cards,
and always opens the first one however large it is. So whichever category you
list first is the one your editors see expanded on every insert, and the rest
start collapsed behind a one-line preview of their contents.

Put the category editors reach for most often first. A first category holding
more than a dozen blocks fills the budget on its own and leaves every other
category collapsed — which is the right shape for a large registry, and the
wrong one if your most-used blocks are spread across two small groups.

#### Migrating a registry that already uses the CMS categories

Start from what your blocks actually declare today:

```bash
grep -rhoE 'category: "[^"]+"' --include="*.tsx" --include="*.ts" . \
  | grep -v node_modules | sort | uniq -c | sort -rn
```

Then work in one commit, because the build fails in between: add the
`categories` list, change each block's `category` to its new key, and keep any
built-in key you still want by declaring it explicitly. The build-time throw is
your migration checklist — it names the first block whose category is not in
the list, one at a time, until the registry is consistent.

#### Verifying it actually reached the CMS

The categories only reach the CMS through the editor handshake, so a green
build and passing unit tests do not prove the feature works — they prove the
registry is well-formed. Open a page in the CMS editor against your running
app and check the **Add block** modal for three things:

1. your labels, not the CMS's five;
2. your order;
3. **no header showing a raw key** — a heading like `parish` where you expected
   "Parish life" means that block's `category` is not in the declared list, so
   the picker fell back to grouping it under the raw key.

If the picker still shows the built-in categories, diagnose from the producer
inward: is `@glokki-cms/react` on 0.9.0 (an older one silently sends no
categories at all, with no error anywhere), does the handshake complete at all
(check the browser console), and did `createBlockRegistry()` actually receive a
second argument.

---

## 8. Advanced Patterns

### Array Fields (Repeatable Items)

Use `z.array(z.object({...}))` for lists of structured items — this is the one place an object belongs in a block schema. Each row object's fields become sub-fields in the PropsPanel, and the row's own fields follow the same [flat-fields rule](#fields-must-be-flat) (scalars and arrays only). Add `.meta({ fieldType: "repeater" })` to get the repeater control, with drag-and-drop row reordering:

```ts
const faqItemSchema = z.object({
  question: z.string().min(1).max(200).describe("Question"),
  answer: z
    .string()
    .min(1)
    .max(1000)
    .meta({ fieldType: "textarea" })
    .describe("Answer"),
})

const faqSchema = z.object({
  heading: z.string().max(120).optional().describe("Section Heading"),
  items: z
    .array(faqItemSchema)
    .default([
      { question: "What is this?", answer: "A frequently asked question." },
    ])
    .describe("FAQ Items"),
})
```

The editor renders array fields with add/remove/reorder controls, and each item expands to show its sub-fields.

### Viewport-Responsive Defaults

Use `viewportDefaults` to set different prop values per breakpoint:

```ts
export const BannerBlock = defineBlock({
  type: "banner",
  label: "Banner",
  category: "content",
  icon: "Megaphone",
  defaultProps: {
    heading: "Announcement",
    columns: 3,
    showImage: true,
  },
  propsSchema: bannerSchema,
  component: BannerComponent,
  viewportDefaults: {
    tablet: { columns: 2 },
    mobile: { columns: 1, showImage: false },
  },
})
```

Viewport defaults are serialised into the block definition and sent to the CMS during the handshake. The editor can use these to suggest different prop values per breakpoint.

### Slots (Container Blocks)

Slots let a block contain child blocks in named areas:

```ts
export const TwoColumnBlock = defineBlock({
  type: "two-column",
  label: "Two Columns",
  category: "layout",
  icon: "Columns2",
  defaultProps: {},
  propsSchema: z.object({}),
  component: TwoColumnComponent,
  slots: [
    { name: "left", label: "Left Column" },
    { name: "right", label: "Right Column" },
  ],
})
```

For dynamic slots (where the number depends on props), pass a function:

```ts
slots: (props) =>
  Array.from({ length: props.columns }, (_, i) => ({
    name: `column-${i}`,
    label: `Column ${i + 1}`,
  })),
```

Slots can restrict which block types are allowed via `allowedTypes`:

```ts
slots: [
  {
    name: "content",
    label: "Content Area",
    allowedTypes: ["rich-text", "image", "cta"],
  },
],
```

### Hidden Fields

Use `.meta({ hidden: true })` for computed or internal fields that shouldn't appear in the editor:

```ts
srcSet: z
  .string()
  .optional()
  .meta({ hidden: true })
  .describe("Computed srcset"),
```

---

## 9. Testing Blocks Locally

### Run the CMS and Tenant App Together

1. Start your tenant app:

   ```bash
   cd /path/to/your-app
   bun dev  # runs on http://localhost:3001
   ```

2. Point the CMS at it:
   - Go to **Dashboard → Settings → Visual Editor**
   - Set **Frontend URL** to `http://localhost:3001`

   The editor iframe runs in your browser, so the hosted CMS can load an app on `localhost`. The setting applies to the whole tenant, though: everyone else editing that tenant now gets your local app too. Use a separate development tenant, or set the URL back to your deployed app when you are done.

3. Open a page in the visual editor — your tenant app loads in an iframe inside the CMS. The handshake sends your block definitions to the CMS, which builds the Component Library and PropsPanel dynamically.

### Verify Block Registration

Open a page in the visual editor. If the handshake succeeds, your new block should appear in the **Component Library** panel. You can also inspect `postMessage` traffic in the browser DevTools (Application → Frames or Console → filter by `handshake`) to confirm the handshake completed.

If your block doesn't appear in the Component Library:

- Check the console for errors in `defineBlock()` (e.g., non-`z.object()` schema)
- Verify the block is included in `createBlockRegistry()`
- Confirm the registry is passed to `<VisualEditorProvider blockRegistry={...}>`

### After Every Deploy: Open the Visual Editor Once

The CMS keeps a **snapshot** of your block registry (and your layout registry) for everything that runs outside the browser — the publish check, `validate_page`, and the MCP tools. That snapshot is refreshed **only** when someone opens a page in the visual editor: every successful editor handshake re-sends the registry. Deploying your app does **not** refresh it, and an MCP client or API call cannot trigger the refresh itself.

So after every deploy that adds a block, adds or changes a field, or changes a block's slots, **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 server side still works from the old snapshot:

- the MCP `whoami` tool still reports the old `blockCount` and `schemaSyncedAt`, and `list_block_types` / `get_block_schema` don't know the new block (`BLOCK_TYPE_NOT_FOUND`);
- `add_block` with the new type is refused with `BLOCK_TYPE_NOT_FOUND`;
- publishing a page that contains the new block fails with `BLOCK_VALIDATION_FAILED` ("Unknown block type … not in tenant registry"), and a field whose type changed is still validated against its old type;
- a tenant that has never opened the editor has no snapshot at all — publishing and the MCP block tools refuse with `REGISTRY_NOT_SYNCED`.

A brand-new prop on an existing block is the one case that isn't refused: props the snapshot doesn't know about are stored without validation until the next sync.

### Troubleshooting Editor Mode

| Symptom                                                                       | Cause                                                                                 | Fix                                                                                                          |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| "Unknown Block" placeholder                                                   | Block type not in registry                                                            | Add to `createBlockRegistry()` and component `Map`                                                           |
| Block renders but props panel is empty                                        | Schema not serialised                                                                 | Ensure `propsSchema` is a `z.object()`                                                                       |
| Field label shows camelCase key                                               | Missing `.describe()`                                                                 | Add `.describe("Human Label")` to the field                                                                  |
| Field shows as plain text input                                               | Missing `.meta()` override                                                            | Add `.meta({ fieldType: "richtext" })` etc.                                                                  |
| Field shows as plain text input despite `.meta()` / is a boolean or number    | Nested `z.object()` field, or a `.catch()` / `.nullable()` / `z.preprocess()` wrapper | Flatten the field; wrap only with `.default()` / `.optional()` ([Fields Must Be Flat](#fields-must-be-flat)) |
| New block missing from MCP / publish says "Unknown block type" after a deploy | The CMS's registry snapshot predates the deploy                                       | Open any page in the visual editor once                                                                      |
| Iframe blocked                                                                | CSP misconfiguration                                                                  | Set `frame-ancestors 'self' https://your-cms.com`                                                            |
| Handshake timeout                                                             | Origin mismatch                                                                       | Check `frontendUrl` matches your dev URL                                                                     |

### Unit Testing Blocks

Use Vitest to test block schemas and components:

```ts
// blocks/testimonial.test.tsx
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"

import { TestimonialBlock } from "./testimonial"

describe("TestimonialBlock", () => {
  it("has correct metadata", () => {
    expect(TestimonialBlock.type).toBe("testimonial")
    expect(TestimonialBlock.label).toBe("Testimonial")
    expect(TestimonialBlock.category).toBe("content")
  })

  it("serialises schema to field descriptors", () => {
    const fieldNames = TestimonialBlock.fields.map((f) => f.name)
    expect(fieldNames).toContain("quote")
    expect(fieldNames).toContain("author")
    expect(fieldNames).toContain("rating")
  })

  it("maps field types correctly", () => {
    const quoteField = TestimonialBlock.fields.find((f) => f.name === "quote")
    expect(quoteField?.fieldType).toBe("textarea")

    const avatarField = TestimonialBlock.fields.find((f) => f.name === "avatar")
    expect(avatarField?.fieldType).toBe("image")

    const ratingField = TestimonialBlock.fields.find((f) => f.name === "rating")
    expect(ratingField?.fieldType).toBe("number")

    const styleField = TestimonialBlock.fields.find((f) => f.name === "style")
    expect(styleField?.fieldType).toBe("select")
    expect(styleField?.options).toEqual(["card", "minimal", "quote"])
  })

  it("renders with default props", () => {
    const Component = TestimonialBlock.component as React.FC<
      typeof TestimonialBlock.defaultProps
    >
    render(<Component {...TestimonialBlock.defaultProps} />)
    expect(screen.getByText(/This product changed everything/)).toBeTruthy()
    expect(screen.getByText("Jane Doe")).toBeTruthy()
  })
})
```

Run tests with:

```bash
npx vitest run blocks/testimonial.test.tsx
```

---

## 10. Checklist

Before considering a block complete:

- [ ] Schema uses `z.object()` as the root type
- [ ] Every field is flat — no nested `z.object()` fields, and no wrapper besides `.default()` / `.optional()`
- [ ] Every field has `.describe("Label")` for a human-readable label
- [ ] String fields that need special controls have `.meta({ fieldType: "..." })`
- [ ] `defaultProps` in `defineBlock()` matches `.default()` values in the schema
- [ ] Component handles optional props gracefully (no crashes on `undefined`)
- [ ] Component defaults in the function signature match `defaultProps`
- [ ] Block is added to `createBlockRegistry()` in the registry file
- [ ] Block appears in the Component Library when testing in editor mode
- [ ] PropsPanel shows correct field types and labels
- [ ] Unit tests cover metadata, field mapping, and rendering
- [ ] After deploying, opened the visual editor once so the CMS snapshot picks up the change

---

## Next Steps

- [Integration Guide](/docs/integration) — full project setup and visual editor integration
