# glokki

---

Source: https://glokki.com/

[Developer docs are live — SDK, blocks, APIs & MCP](https://glokki.com/docs)

# Build, translate, and test web pages — visually.

The multi-tenant CMS that puts a visual page builder, multilingual content, A/B tests and AI insights in one place — on top of blocks your developers write in TypeScript.

- [Create free account](https://app.glokki.com/register?utm_source=home-hero)
- [Read the integration guide](https://glokki.com/docs/integration)

```bash
npm i @glokki-cms/sdk @glokki-cms/react
```

## Built for teams who ship

### Visual builder + i18n

Drag-and-drop blocks, real-time preview, and built-in translations across every page.

### A/B testing

Run experiments on copy and layout. Measure conversion lift without leaving the editor.

### AI insights

Catch SEO, accessibility, and performance issues before publish — automatically.

## One CMS for everyone who touches the site.

Marketers edit, agencies manage clients, developers own the code. Nobody waits on anybody.

### Marketers

- Visual editing — no code needed
- Instant publish + scheduled releases
- A/B variants on any page or section
- Translate pages into any language

[Start editing →](https://app.glokki.com/register?utm_source=home-marketers)

### Agencies

- Multi-tenant — manage many clients
- White-label branding per tenant
- Clients host their own published sites
- Scoped permissions per workspace

[Set up your agency →](https://app.glokki.com/register?utm_source=home-agencies)

### Developers

- Block SDK in TypeScript
- Headless delivery API
- Zod-validated block schemas
- Bring your own Postgres + S3

[Browse the docs →](https://glokki.com/docs)

For developers

## Your React components. Your schema. Our editor.

Define blocks in TypeScript; Zod schemas drive the props panel, your components render in production. No proprietary templating language — and every step is documented.

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

import { Hero } from "./hero"

export const heroBlock = defineBlock({
  type: "hero",
  label: "Hero",
  category: "content",
  icon: "Sparkles",
  propsSchema: z.object({
    title: z.string(),
    subtitle: z.string().optional(),
  }),
  defaultProps: { title: "" },
  component: Hero,
})
```

[Integrate with Next.js](https://glokki.com/docs/integration)

[Build your first block](https://glokki.com/docs/block-development)

### Everything is in the docs

- [Getting started](https://glokki.com/docs/integration) — Integration guide and troubleshooting for Next.js.
- [Blocks & layouts](https://glokki.com/docs/block-development) — Custom blocks, components, header/footer layouts, preview route.
- [APIs & SDK](https://glokki.com/docs/api-reference) — SDK reference, asset import and data model write APIs.
- [AI & automation](https://glokki.com/docs/mcp-server) — MCP server and Markdown for agents.
- [Infrastructure](https://glokki.com/docs/page-analytics) — Analytics, asset CDN proxy, Postgres caching.

Agent-ready

## Let your AI agents edit the site too.

Connect Claude or any MCP client to create pages, add blocks and publish. Every page — docs included — is also served as Markdown, with llms.txt for discovery.

```bash
# any page, as Markdown
curl -H "Accept: text/markdown" https://glokki.com/docs

# site map for LLMs
curl https://glokki.com/llms.txt
```

- [MCP server guide](https://glokki.com/docs/mcp-server)
- [Markdown for agents](https://glokki.com/docs/markdown-for-agents)

## Everything you need to ship

### Forms

Capture leads with branded forms.

### Collections

Define structured content models per tenant.

### Media library

Optimize and serve images automatically.

### Workflows

Draft → review → publish, with custom states.

### Webhooks

Notify external systems on every change.

### Performance dashboard

Real-time core web vitals per page.

## Questions

Something missing? Get in touch through the contact page — we usually reply within one business day.

### Is Glokki free?

Yes — Glokki is free during the beta. Create an account to get your own workspace; paid plans will follow after the beta.

### Can I self-host?

The editor runs only on glokki.com — it is not self-hostable. Your published site is always hosted by you, on the infrastructure of your choice.

### How does multi-tenancy work?

By default, each tenant gets isolated data via row-level security on Glokki's shared Postgres, plus white-label branding. You can optionally bring your own Postgres database and S3 bucket — Glokki will write your content and assets to the storage you control.

### Can I import from WordPress or Contentful?

There is no automated importer in v1. Manual content migration is straightforward via the delivery API; an importer is a candidate follow-up.

### What about GDPR?

Glokki only collects data you provide (account email, content). The Glokki editor runs in a single EU region; you choose where your published site is hosted. Data processing agreements are available on request.

### Do you offer support?

During the beta, reach us through the contact page — you'll typically hear back within one business day.

### Is the source open?

No. Glokki is closed-source and will remain so. The Block SDK and headless delivery API give you everything you need to integrate without access to the editor's source.

## Start building today.

Free during beta · No credit card required

[Create free account](https://app.glokki.com/register?utm_source=home-cta)

[Read the docs](https://glokki.com/docs)

---

Source: https://glokki.com/blog

# Blog

Engineering notes, guides and product news from the team building glokki, including what we learn running our own site on it.

## Build your site with glokki

Free during beta · No credit card required

[Create free account](https://app.glokki.com/register?utm_source=blog)

[Read the docs](https://glokki.com/docs)

---

Source: https://glokki.com/blog/adding-a-blog-with-glokki-collections

# How we added a blog to our Next.js site with glokki collections

glokki team · Oct 26, 2026

One collection, two blocks and an RSS route. How the glokki.com blog is built, what we reused from the docs section, and the two limits we hit.

The blog you are reading was added to glokki.com in an evening. It is not a
separate app or a Markdown folder in the repo: posts are entries of a glokki
collection, rendered by two blocks, edited in the same visual editor as the
rest of the site. This post walks through how it fits together.

## A collection is the data model

A collection in glokki is a set of pages that share fields and a URL prefix.
The docs on this site are one (`/docs`), the blog is another (`/blog`). We
created it over MCP with one call:

| Field | Type | Used for |
|---|---|---|
| `content` | textarea | Markdown body |
| `summary` | textarea | lead under the title, card text, RSS description |
| `category` | select | Engineering, AI & agents, Product, Guides, Comparisons |
| `cover` | image | card and header image |
| `author` | text | byline |
| `featured` | boolean | the large card at the top of `/blog` |
| `updated` | date | "Updated" line on revised posts |

The post date and topics are not fields: every entry already has
`publishedAt` and tags.

Because every post is a page, the things the site already does for pages
apply to posts with no extra routes: ISR, preview links, the visual editor,
`/sitemap.xml`, and a Markdown version of every page for agents
(`/blog/<slug>.md`).

## Block one: the post

`blog-post` renders a single entry. Its props are not typed in by an editor;
they are bound to the page and to the collection fields:

```ts
const blogPostSchema = z.object({
  title: pageField("title"),
  slug: pageField("slug"),
  publishedAt: pageField("publishedAt"),
  summary: collectionField("summary"),
  content: collectionField("content"),
  category: collectionField("category"),
  cover: collectionField("cover"),
  author: collectionField("author"),
  showCover: z.boolean().default(true),
})
```

The component draws the byline (author, date, reading time computed from the
body), the cover, the Markdown body with the same renderer and outline rail
as the docs, and a `BlogPosting` JSON-LD script. The block also declares how
it renders to Markdown, so the agent-facing version of a post is the title,
byline, summary and body, without the page chrome.

## Block two: the list

`blog-list` shows posts newest first, in two variants:

- `grid` for `/blog`: a featured card, then a card grid, with category
  filters.
- `compact` for under a post ("More from the blog") or the home page: date
  and title, three rows.

Lists are the part that is easy to get slow. A naive block fetches entries
in the browser after hydration, and fetches every field, including every
post's full body. We had already solved this for the docs sidebar, so the
blog reuses the pattern:

1. Before rendering, the page walks its block tree and collects one query per
   `blog-list` it finds.
2. The server fetches those entries (`sortBy: "published_at"`, newest
   first) and trims them to the fields a card shows — never `content`.
3. A context provider hands them to the blocks, so the list is in the HTML.

Inside the visual editor the page renders client-side, so the block falls
back to fetching through a small same-origin proxy, with the same trimming.

## RSS in one route

`/blog/rss.xml` is a plain Next.js route handler: list the latest 30
entries, map them to `<item>`s, return `application/rss+xml`. The only
detail worth knowing is routing. The site serves every CMS page from one
catch-all route, and a static segment under `app/blog/` only claims
`/blog/rss.xml`, so `/blog` and the posts still go through the catch-all.

Blog pages advertise the feed with `<link rel="alternate">`, and only pages
that exist: our first version also put it on the 404 page under `/blog`.

## Publishing a post refreshes the index

Pages are cached with ISR and purged by a signed webhook when something is
published. The webhook purges the path that changed — which is the post, not
the `/blog` index that lists it. One line fixes that: when the published
slug starts with `/blog/`, purge `/blog` too.

## Writing posts

Posts can be written in the editor, but ours start as Markdown files in the
repo, with front matter for the fields. A script imports them over the MCP
server: it matches entries by slug, creates missing ones as drafts, fills in
the fields and seeds the three blocks a post uses. Nothing is published
until someone publishes it.

## Two limits we hit

- **Tags can't be set over MCP yet.** `create_page` takes collection fields
  but not tags, so the import script prints them and we add them in the
  editor.
- **A post page doesn't know its own tags.** Tags come back on list entries,
  not on the delivered page, so the post shows its category but not its
  topics.

Both are on our list for the CMS. Neither stopped us from shipping.

## Build your own

Everything here uses the public SDK: collections, `defineBlock()` with
`pageField()` / `collectionField()` bindings, and the delivery API. The
[block development guide](/docs/block-development) covers bindings, and the
[integration guide](/docs/integration) covers the catch-all route, ISR and
the revalidation webhook.

[Create a free account](https://app.glokki.com/register?utm_source=blog&utm_campaign=blog-with-collections)
and add a blog to your own site.

# More from the blog

## Add a blog to your own site

Free during beta · No credit card required

[Create free account](https://app.glokki.com/register?utm_source=blog&utm_campaign=blog-with-collections)

[Read the block guide](https://glokki.com/docs/block-development)

---

Source: https://glokki.com/blog/cookieless-page-analytics

# Cookieless page analytics: what you can measure without a consent banner

glokki team · Oct 26, 2026

Views, daily unique visitors, referrers and devices, with nothing stored in the browser. How glokki page analytics works, what it deliberately leaves out, and the two files it takes to set up.

Most cookie banners exist because of analytics. A script sets an identifier
in the browser so it can recognise a visitor tomorrow, and storing that
identifier is what needs consent. glokki ships a page counter that doesn't
store anything in the browser, which is why glokki.com has no banner.

This post covers what it measures, how it gets away without cookies, what
it deliberately leaves out, and the setup.

## What you get

The **Analytics** dashboard in the CMS shows, for 7, 30 or 90 days:

- **Views**: total page views, with a trend chart.
- **Unique visitors**: distinct visitors, counted per day.
- **Referrers**: where the traffic came from.
- **Devices**: desktop, mobile and tablet, worked out on the server from the
  User-Agent.

There's no separate analytics account and no third-party script tag. If the site
renders glokki pages, the data appears as soon as the two pieces below are
deployed.

## How it works

There are two parts: a component in the page and a route on your own
domain.

```text
browser ── POST /_e { path, referrer } ──► your app (same origin)
                                            │ + visitor IP, User-Agent
                                            ▼
                                   glokki ingest ──► hash, count, discard IP
```

**The beacon.** `<PageAnalytics />` from `@glokki-cms/react` renders
nothing. After the page loads, and on every client-side navigation, it
sends one request with exactly two fields:

```ts
JSON.stringify({ path: pathname, referrer: document.referrer || undefined })
```

It uses `navigator.sendBeacon` (or a `keepalive` fetch where that is
missing), so the request never blocks rendering and still goes out if the
visitor clicks away straight after.

**The proxy.** The beacon doesn't talk to a third-party domain. It posts to
`/_e` on your own site, a route that `createInsightsRoute()` from
`@glokki-cms/sdk` creates. The route holds the API key on the server, adds
the visitor's IP address and User-Agent, and forwards the beacon to glokki.
It replies `204` immediately and ignores upstream errors, so analytics can't
break or slow down a page.

**Unique visitors without an identifier.** glokki computes a SHA-256 hash of
IP + User-Agent with a salt that changes every day. The raw IP is used for
that hash and then discarded, not stored. Since the salt changes daily,
today's hash can't be matched to yesterday's. That's what "unique per day"
means, and why nothing has to be kept in the browser.

## Why there is no banner

The consent requirement for cookies (in the EU, the ePrivacy rules) is about
**storing or reading information on the visitor's device**. This setup does
neither: there are no cookies, nothing in `localStorage` and no fingerprint
script. You can check glokki.com yourself. Its pages send no `Set-Cookie`
header, and the analytics request carries only a path and a referrer.

We're not your lawyers, though. An IP address is still personal data while
it is being processed, so describe the analytics in your privacy policy, and
ask someone qualified if your situation is unusual.

## What it doesn't measure

These are choices, not missing features:

- **Returning visitors.** No identifier survives the day, so "came back on
  Tuesday" can't be recorded.
- **Sessions, funnels and events.** It counts page views. It doesn't track
  clicks, scroll depth or sign-ups.
- **Campaign tags.** The beacon sends the path without the query string, so
  `?utm_source=…` doesn't reach the stats. The referrer usually tells you
  enough. For campaign attribution, look where the conversion happens: on
  glokki.com the sign-up links carry UTM tags, and the sign-up app is the
  place to count them.
- **Every last visitor.** A same-origin route on a neutral path avoids most
  blocklists aimed at third-party analytics, but any client-side counting
  can be blocked. Expect a small undercount. Bots are filtered out in the
  CMS.

If you need funnels or product analytics, use a tool built for that, and ask
for consent. For "which pages do people read and where do they come from",
this is enough.

## It counts cached pages

glokki.com pages are static and served from the CDN with ISR. Counting on
the server would miss every cache hit. The beacon runs in the browser after
the HTML arrives, so a page served from the edge counts the same as a fresh
render.

## It doesn't count you

Editors open a page dozens of times while working on it. Since
`@glokki-cms/react` 0.10, the beacon does nothing when the page is opened by
the CMS: inside the visual editor (`?_editor=1`), on the block-template
editor route, and in draft previews (`?preview=true`). Earlier versions
counted those views, and the referrer pointed at the CMS. If your numbers
look higher than your traffic, check the version first.

On glokki.com we went one step further. `<PageAnalytics />` lives in the
component that renders public CMS pages, not in the root layout, so preview
and editor routes never include it.

## Setup: two files

The route:

```ts
// app/_e/route.ts — use the folder name %5Fe: Next.js treats _e as private
import { createInsightsRoute } from "@glokki-cms/sdk"

export const { POST } = createInsightsRoute({
  baseUrl: process.env.CMS_BASE_URL!,
  tenantSlug: process.env.CMS_TENANT!,
  apiKey: process.env.CMS_API_KEY!, // delivery:read, server-side only
})
```

The component, rendered once on public pages:

```tsx
import { PageAnalytics } from "@glokki-cms/react"

// …inside your page or layout
<PageAnalytics />
```

If you mount the route somewhere other than `/_e`, pass the same path as
`<PageAnalytics endpoint="/your-path" />`. The
[Page Analytics guide](/docs/page-analytics) covers IP headers behind
Cloudflare or Fastly, older SDK versions and troubleshooting.

[Create a free account](https://app.glokki.com/register?utm_source=blog&utm_campaign=cookieless-analytics)
and see your traffic without adding a banner.

# More from the blog

## See your traffic without a cookie banner

Free during beta · No credit card required

[Create free account](https://app.glokki.com/register?utm_source=blog&utm_campaign=cookieless-analytics)

[Read the guide](https://glokki.com/docs/page-analytics)

---

Source: https://glokki.com/blog/edit-your-website-from-claude-code

# Edit your website from Claude Code with the glokki MCP server

glokki team · Oct 26, 2026

Connect Claude Code to your glokki workspace in two minutes, then let it create a page, fill it with your own blocks, check it and publish it — with you approving each step.

glokki ships with an MCP server. Any client that speaks the Model Context
Protocol, such as Claude Code or Cursor, can use it to read and edit
your site with the same blocks and permissions your editors have. This guide
connects Claude Code and walks through one page, from empty to published.

## What you need

- A glokki workspace with your site connected (the
  [integration guide](/docs/integration) covers this).
- Your blocks deployed, and the visual editor **opened once after that
  deploy**. The MCP server works from a snapshot of your block registry that
  refreshes when the editor loads, so a block it has never seen doesn't
  exist for the agent yet.
- [Claude Code](https://claude.com/claude-code) installed.

## 1. Create an API key

In the dashboard, open **Settings → API Keys** and create a key. Pick only
the scopes the agent needs:

| Scope | Lets the agent |
|---|---|
| `mcp:connect` | connect at all (required) |
| `admin:pages:read` | list and read pages, blocks, collections, layouts |
| `admin:pages:write` | create pages, add and edit blocks, SEO, layouts |
| `admin:pages:publish` | publish and unpublish |

Start without `admin:pages:publish`. The agent can then build drafts, and
publishing stays a button you press — you can add the scope later.

The key is shown once. Copy it.

## 2. Connect Claude Code

```bash
claude mcp add --transport http glokki https://app.glokki.com/api/mcp/mcp \
  --header "Authorization: Bearer ek_live_…"
```

Then, in Claude Code, run `/mcp`. It calls `whoami` and shows your
workspace. Check `blockRegistry.schemaSyncedAt` while you're there: if it is
older than your last deploy, open the visual editor once.

## 3. Ask for a page

Describe the page the way you'd brief a colleague:

> Create a draft page at /pricing-faq using our existing blocks: a hero with
> the heading "Pricing questions", then an FAQ with these five questions, then
> the CTA band pointing at sign-up. Don't publish.

What the agent does with that, tool by tool:

1. **`list_block_types`** and **`get_block_schema`** — to learn which blocks
   your site has and what fields each takes. It works with *your* blocks,
   not generic ones.
2. **`create_page`** — a draft at `/pricing-faq`. It returns an `updatedAt`
   token.
3. **`add_block`** for each section, passing the token from the previous
   call each time. The token is an optimistic lock: if someone edits the
   page in the dashboard meanwhile, the agent's next write is refused
   instead of overwriting their work.
4. **`update_seo`** — title and description.
5. **`validate_page`** — a dry run of the same checks publishing runs. Any
   problem comes back as a field path, so the agent can fix it.

Open the draft in the visual editor and look at it. Change a word by hand if
you like — the agent's next edit will notice, because the token moved.

## 4. Publish

If the key has `admin:pages:publish`, say "publish it" and the agent calls
**`publish_page`**. Otherwise publish from the dashboard.

## Editing what's already live

The same loop works on existing pages, and this is where an agent saves the
most time: changes that touch many places. On glokki.com we switched every
"Request access" button to self-serve sign-up — header, footer, hero,
segment cards, the CTA band and an FAQ answer — in one request. The agent
found them with `get_page` and `get_layout`, changed them with
`update_block_props` and `set_layout_field`, validated, and published.

Two behaviours worth knowing:

- **Editing a published page makes it a draft.** The live version stays up,
  but nothing changes for visitors until the page is published again.
- **Lists are replaced, not merged.** To change one FAQ answer, the agent
  reads the whole list, edits it, and sends it back.

## What it can't do (yet)

- Open the visual editor to sync new blocks — a person has to.
- Set tags on collection entries, manage redirects, or add JSON-LD; those
  are done in the dashboard.

The full tool list, rate limits and error codes are in the
[MCP server guide](/docs/mcp-server).

[Create a free account](https://app.glokki.com/register?utm_source=blog&utm_campaign=claude-code-mcp)
and connect your first agent.

# More from the blog

## Connect your first agent

Free during beta · No credit card required

[Create free account](https://app.glokki.com/register?utm_source=blog&utm_campaign=claude-code-mcp)

[Read the MCP guide](https://glokki.com/docs/mcp-server)

---

Source: https://glokki.com/blog/how-visual-editing-works-in-a-headless-cms

# Visual editing in a headless CMS: how it works

glokki team · Oct 26, 2026

Your real Next.js pages in an iframe, a postMessage handshake that sends the CMS your block schemas, and a proxy that keeps public pages static. How glokki's visual editor works, and what we got wrong on our own site.

A headless CMS usually comes with a trade-off. Developers own the frontend,
and editors fill in forms and press "preview" to see what they did. Visual
editing removes the second half of that: editors click a heading on the real
page and change it in place.

The hard part is doing it without the CMS owning the frontend. glokki's
editor doesn't have its own renderer or a copy of your components. It shows
**your** site, running **your** code, and talks to it. Here's how.

## Your page, in an iframe

In the CMS settings you set a **Frontend URL**, the address of your app. To
edit a page, the CMS loads that page in an iframe with three extra
parameters:

```text
https://glokki.com/about?_editor=1&_editorToken=<token>&_editorOrigin=https://app.glokki.com
```

Your app sees `_editor=1` and renders an editor shell instead of the
published page:

```tsx
<VisualEditorProvider
  registry={registry}
  blockRegistry={blockRegistry}
  layoutRegistry={layoutRegistry}
  pageSlug={pageSlug}
>
  <CMSPageEditor />
</VisualEditorProvider>
```

Both components come from `@glokki-cms/react`. Everything in the iframe is
your app: your blocks, your Tailwind classes, your fonts. If a block looks
wrong in the editor, it looks wrong on the site too.

## The handshake

The iframe and the CMS are on different origins, so they talk through
`postMessage`:

```text
iframe (your app)                          CMS
  │── handshake: block registry ──────────►│  schemas, defaults, metadata
  │◄───────────── page tree + editor state ─│
  │◄───────────── tree:update ──────────────│  on every edit in the props panel
  │── selection, hover, drag ─────────────►│
```

1. **The app introduces itself.** The provider sends its block registry:
   every block's type, Zod props schema, defaults and metadata.
2. **The CMS builds the forms.** The props panel is generated from those
   schemas. `z.string()` becomes a text field, `z.enum()` a select,
   `z.boolean()` a toggle, `z.array(z.object())` a repeatable list. Nobody
   configures block types in the CMS.
3. **The CMS sends the page.** The iframe gets the current component tree and
   renders it with `<CMSPageEditor>`, which adds the overlays: selection,
   hover outlines, drag handles and the "insert here" gaps.
4. **Edits stream in.** Each change in the props panel arrives as a
   `tree:update` and the iframe re-renders. Nothing is saved to your app.
   The draft lives in the CMS.

So a new block needs no CMS-side setup: deploy it, open the editor, and the
handshake announces it. The catch is in the word *deploy*. The editor loads
your Frontend URL, which is usually production. A block that exists only on
a preview branch doesn't exist as far as the editor is concerned. We found
that out on our own blog: the new blocks didn't appear in the editor until
we merged to `main`.

## Keeping public pages static

Editor and preview requests need per-request rendering: they read query
parameters and draft content. Public pages shouldn't pay for that.
glokki.com serves pages through one catch-all route with ISR, and that route
never reads `searchParams`. Reading them would make every page dynamic.

The split happens in `proxy.ts` (Next.js 16's middleware), before routing.
Requests with `?_editor=1`, or `?preview=true` plus a token, are rewritten
to a separate, dynamic route. The browser URL doesn't change:

```ts
if (isEditorMode || isPreviewMode) {
  const url = request.nextUrl.clone()
  url.pathname = `/_cms${url.pathname}` // force-dynamic route, noindex
  response = NextResponse.rewrite(url)
}
```

The same check decides who may frame the page. You can see both behaviours
on glokki.com with `curl -I`:

| Request | Framing | Caching |
|---|---|---|
| `/` | `X-Frame-Options: SAMEORIGIN` | public, served by ISR |
| `/?_editor=1` | `frame-ancestors 'self' https://app.glokki.com` | `private, no-store` |

A public page can't be embedded by anyone. An editor view can be embedded,
but only by the CMS. Setting `tenantSlug` on the provider adds a second
check: if a Frontend URL ever points at the wrong tenant's app, the
handshake is refused before any draft content is sent.

## Drafts and templates

The same route covers two more cases.

- **Preview links.** `?preview=true&token=…` fetches the draft through a
  client with `preview: true` and `cache: "no-store"`, renders the normal
  page with a "draft" banner, and is marked `noindex`. This is the link you
  send to someone who should see a page before it's published.
- **Block templates.** Templates are saved block trees with no page behind
  them, so there's no URL to load. A small passive route,
  `/_editor-preview`, renders whatever tree the CMS sends it. Outside the
  editor it renders nothing, so it can't be indexed or used as an open
  renderer. It's the one editor URL the proxy must **not** rewrite, but it
  still needs the framing header. The docs didn't mention that when we
  built our site, so it went on our list of gaps. The
  [Editor Preview Route guide](/docs/editor-preview-route) now shows the
  condition.

## Three things to get right

- **Keep the editor and the published page on the same components.** It
  sounds obvious, but a separate "editor version" of a block is how visual
  editors drift from the real site. With one registry used by both routes,
  they can't drift.
- **Don't count editors as visitors.** Editing reloads pages constantly. An
  analytics script that runs in the iframe inflates your numbers. glokki's
  own beacon skips editor and preview views since `@glokki-cms/react` 0.10.
  Check any other scripts you add.
- **Prefer flat schemas.** The props panel is generated from Zod, and
  arrays of objects work well. A nested `z.object()` field is currently
  stored as plain text and can't be set as an object. Our header's mobile
  menu couldn't be configured until we flattened it, as told in
  [our rebuild write-up](/blog/rebuilt-glokki-com-with-an-ai-agent).

## Set it up

The [integration guide](/docs/integration#7-enable-visual-editing) walks
through the editor shell, the CSP proxy, the Frontend URL setting and
preview mode. The [block development guide](/docs/block-development) covers
how schemas become editor fields.

[Create a free account](https://app.glokki.com/register?utm_source=blog&utm_campaign=visual-editing)
and open your own site in the editor.

# More from the blog

## Open your own site in the visual editor

Free during beta · No credit card required

[Create free account](https://app.glokki.com/register?utm_source=blog&utm_campaign=visual-editing)

[Read the docs](https://glokki.com/docs/integration)

---

Source: https://glokki.com/blog/make-your-site-discoverable-by-ai

# How to make your website discoverable by AI: llms.txt, Markdown and JSON-LD

glokki team · Oct 26, 2026

AI assistants read your site differently from a browser. Four things that help them — a site map for LLMs, Markdown versions of pages, structured data and honest crawl rules — and how glokki.com does each.

More and more people ask an assistant instead of a search engine. The
assistant fetches a few pages, reads them and answers — often without the
person ever seeing your site. Whether your page ends up in that answer
depends on how easy it is to read for a program that has a limited budget
of tokens and no patience for cookie banners.

There is no single switch for this, and nobody can promise you a place in
an AI answer. But a few things make a site much easier for agents to read,
and none of them hurt the site for people. Here is what glokki.com does.

## 1. A site map for LLMs: `llms.txt`

[`llms.txt`](https://llmstxt.org) is a proposed convention: a Markdown file
at the root of the site with its name, a one-line summary and a list of
links to the pages that matter, each with a short description. It tells an
agent where to look without crawling the whole site.

Ours is generated from published pages on every request, so it can't go
stale: the docs grouped like the docs sidebar, blog posts newest first, then
the other pages. It is about 4 KB. Pages marked `noindex` are left out.

There is also `/llms-full.txt`, every page's content in one file (about
420 KB for this site), for agents that would rather read everything at once.

## 2. Markdown versions of every page

HTML is a poor format to hand to a model. A typical page is mostly
navigation, scripts and class names, and the content is spread across
components. So every page on glokki.com is also available as Markdown, two
ways:

```bash
# append .md to any URL
curl https://glokki.com/docs/mcp-server.md

# or ask for Markdown on the normal URL
curl -H "Accept: text/markdown" https://glokki.com/docs
```

The second form answers with `Content-Type: text/markdown`, `Vary: Accept`
so caches keep the HTML and Markdown versions apart, and a `Link` header
pointing at the HTML page as canonical.

The Markdown is not scraped from the HTML. Each block on the site declares
how it renders to Markdown, next to its React component — a FAQ block
becomes questions as headings with their answers, a feature grid becomes a
heading per card, and links that only jump within the HTML page (`#faq`)
are dropped. That keeps the Markdown short and keeps it in step with the
page.

## 3. Structured data with JSON-LD

JSON-LD is a `<script type="application/ld+json">` block that states facts
about the page in a vocabulary search engines and assistants understand:
this is an article, published on this date, by this author.

In glokki, any page can carry JSON-LD from its SEO settings, and blocks can
emit their own. We use both: the home page has an `Organization` entry
(name, URL, logo, a one-line description) set in its SEO settings in the
CMS, and blog posts emit `BlogPosting` with the headline, dates, author and
image.

What we don't do yet: the docs have no structured data. `TechArticle` on
each guide is the obvious next step.

## 4. Honest crawl rules and canonical URLs

The basics still apply, and agents rely on them as much as search engines:

- `/sitemap.xml` lists every published, indexable page, with the publish
  date and language alternates.
- `/robots.txt` keeps crawlers out of the editor preview route and API
  routes, and keeps preview deployments out of the index entirely.
- Every Markdown response points back to its HTML page as canonical, so the
  two versions don't compete.

One trap we found while building this site: the SDK helper that builds
page metadata sets `noindex` on everything unless `APP_ENV=production`, and
our docs didn't say so. A site set up by the book would have been hidden
from search engines. If you do nothing else, check your production pages
for a stray `noindex`.

## What this does not do

None of this guarantees that an assistant will cite you. It removes reasons
not to: a page that is easy to find, fast to read and clear about what it
is has a better chance than one that isn't. Content still matters most —
answer the question the person asked, near the top of the page.

## Try it on your site

All four come with glokki: `llms.txt`, Markdown for every page, JSON-LD in
the SEO settings, and the sitemap. The
[Markdown for agents guide](/docs/markdown-for-agents) shows the setup, and
the [sitemap and robots.txt guide](/docs/sitemap-and-robots) covers the
crawl rules.

[Create a free account](https://app.glokki.com/register?utm_source=blog&utm_campaign=ai-discoverability)
and see how your site reads to an agent.

# More from the blog

## Make your site readable by agents

Free during beta · No credit card required

[Create free account](https://app.glokki.com/register?utm_source=blog&utm_campaign=ai-discoverability)

[Markdown for agents guide](https://glokki.com/docs/markdown-for-agents)

---

Source: https://glokki.com/blog/rebuilt-glokki-com-with-an-ai-agent

# We rebuilt glokki.com with an AI agent and our own CMS

glokki team · Oct 26, 2026

Two weeks, 31 commits, one agent. What worked when Claude Code built our site through the glokki MCP server, what broke, and the 18 documentation gaps we found on the way.

In October we rebuilt glokki.com: the home page, a contact page, a
request-access flow and a `/docs` section with 15 guides. We did not write
most of it by hand. An AI agent (Claude Code) wrote the code in the repo and
built the pages inside glokki itself, through the glokki MCP server: adding
blocks, filling in props, publishing pages and editing the navigation.

Every one of the 31 commits between 8 and 21 October is co-authored by the
agent.
This post is what that looked like, including the parts that did not work.

## The setup

glokki.com is an ordinary glokki tenant: a Next.js app that renders pages
from the delivery API. There are two kinds of work on a site like this, and
the agent did both:

- **Code, in the repo.** Blocks are React components with a Zod schema,
  declared with `defineBlock()`. The agent wrote the hero, the bento grid,
  the FAQ, the docs shell, sidebar and article blocks, and the plumbing
  around them: ISR, the revalidation webhook, the asset and OG-image
  proxies, `sitemap.xml`, `robots.txt`.
- **Content, in the CMS.** Once a block is deployed, it shows up in the
  editor, and the MCP server lets an agent use it the same way an editor
  would: `create_page`, `add_block`, `update_block_props`, `publish_page`,
  `add_nav_item`, `set_layout_field`.

```text
agent ──► repo:  defineBlock({ type: "hero", propsSchema, component })
      ──► deploy
      ──► MCP:   add_block(page, "hero") → update_block_props → publish_page
```

That split matters. The agent never edits content by writing JSON into a
file, and nobody has to click through the editor to try out a block the
agent just wrote. A marketer can open the same page later and change a
headline in the visual editor without knowing an agent built it.

## What worked

**Docs from Markdown, in one script.** Our guides live as Markdown. The
agent wrote `scripts/import-docs.ts`, which talks to the MCP server
directly: it matches entries by slug, creates missing ones as drafts,
fills in category and order, seeds the block tree and publishes. It is
idempotent and retries on rate limits, so we re-run it whenever the guides
change.

**Agent-readable output.** Since an agent was building the site, it made
sense to make the site readable by agents too. Every page is also served as
Markdown (`/docs/mcp-server.md`, or `Accept: text/markdown`), and
`/llms.txt` and `/llms-full.txt` list the whole site. Each block declares
how it renders to Markdown, next to its React component, so the two never
drift apart.

**Performance work with numbers.** Public pages moved to ISR and the docs
sidebar is now rendered on the server, trimmed to the fields it shows:
about 3 KB for the whole list of guides, against about 420 KB with every
guide's full text (measured on 25 October).

**Accessibility as a pass/fail check.** The agent ran axe on every page
and fixed what it found until the whole site came back clean for WCAG 2.2
AA. With an agent, "make it pass" is a better instruction than "make it
accessible".

## What broke

Building your own site on your own product is the fastest way to find out
what your docs leave out. We kept a list as we went and ended up with 18
gaps in the glokki documentation and SDK. A few that mattered:

- **Production silently set to `noindex`.** `cmsMetadata()` adds
  `noindex, nofollow` unless `APP_ENV=production`, and the docs did not
  mention `APP_ENV` anywhere. Following the guide to the letter would keep
  a site out of Google without any warning.
- **Images never cached on Vercel.** The CMS sends
  `Cache-Control: public, max-age=31536000, immutable`, but Vercel's CDN
  does not cache function responses without `s-maxage` or
  `CDN-Cache-Control`, so every image request ran a function. Fixed in
  `@glokki-cms/sdk` 0.17: the proxies add `CDN-Cache-Control` themselves.
- **A nested field the editor could not set.** A `z.object()` field in a
  block schema is serialized as plain text, so the MCP server rejected the
  object the agent sent and the header's mobile menu could never be
  configured. Phones had no menu until we flattened the schema.
- **The agent cannot see a new block until a human opens the editor.**
  The MCP server works from a schema snapshot that refreshes when the
  editor is opened. After every deploy that adds a block, `add_block`
  rejects it until someone opens the editor once. This was the only step
  in the loop that needed a person.

Smaller ones: `update_block_props` cannot delete a prop, only blank it;
header and footer share one draft token, so editing one makes the other
stale; opening a page in the editor flips its status to draft. Each one
cost the agent a failed call and a retry, and cost us a line in the list.

## What we would do differently

- **Flat block schemas from the start.** Arrays of objects work, nested
  objects do not (yet). Designing blocks around that would have saved a
  redesign of the header.
- **Write the gap list in the open.** We kept it in the repo, but most of
  it belongs in the docs, so the next team does not have to find the same
  problems.
- **Let the agent verify, not just build.** The best results came from
  tasks with a check at the end: typecheck, axe, a request against the
  live page, a diff of the Markdown output. "Done" with no check meant
  nothing.

## Try it

The MCP server is part of every glokki workspace. Point Claude Code,
Cursor or any MCP client at it and an agent can build pages from your own
blocks. The setup is in the [MCP server guide](/docs/mcp-server), and the
site you are reading is the reference integration.

[Create a free account](https://app.glokki.com/register?utm_source=blog&utm_campaign=rebuilt-with-an-agent)
and try it on your site.

# More from the blog

## Let an agent build your next page

Free during beta · No credit card required

[Create free account](https://app.glokki.com/register?utm_source=blog&utm_campaign=rebuilt-with-an-agent)

[Read the MCP guide](https://glokki.com/docs/mcp-server)

---

Source: https://glokki.com/blog/redirects-and-utm-without-code

# Redirects and UTM tags without touching code

glokki team · Oct 26, 2026

We retired a page and sent its traffic to sign-up with a single CMS rule — regex, UTM tags and all. Here's how, and the two mistakes that briefly sent visitors to a 404.

When sign-up became self-serve, our "request access" page had nothing left
to do. It still had links pointing at it — old posts, bookmarks, a few
emails — so deleting it would have turned those into 404s. We redirected it
instead, and tagged the redirect so sign-ups that come through old links
show up as their own source.

It took one rule in the CMS. It also took two attempts, and the first one
is the more useful part of this post.

## The rule

In the dashboard, **Settings → Redirects**:

| Field | Value |
|---|---|
| Source | `^/request-access(/.*)?$` |
| Regex | on |
| Target | `https://app.glokki.com/register?utm_source=request-access` |
| Type | temporary while testing (ours is permanent now that it's final) |
| Preserve query string | on |

- **Regex** lets one rule cover the page and everything under it — here,
  the old thank-you page too.
- **The target carries its own UTM tag**, so in analytics these sign-ups
  arrive as `utm_source=request-access`.
- **Preserve query string** passes the visitor's own parameters through. A
  link that already had `?utm_source=newsletter` keeps it: the visitor's
  tags win over the rule's.

Rules apply on the site within a minute, with no deploy. Once the redirect
worked, we unpublished the old pages so they dropped out of the sitemap and
`llms.txt`.

## Mistake 1: a relative target

Our first version had `/register?utm_source=request-access` as the target.
A relative path is resolved against the site, not the app, so visitors went
to `glokki.com/register` — which doesn't exist. For sign-up on another
domain, the target has to be the full URL.

## Mistake 2: permanent while testing

The first rule was also **permanent** (301). Browsers cache a permanent
redirect, often for a long time, and stop asking the server. Anyone who hit
the broken rule in those few minutes can keep landing on the 404 even after
the fix, until their browser forgets it.

Use **temporary** (302) while you're setting a redirect up, check it, and
switch to permanent only when it's final — or keep it temporary if the
target might change again. Search engines handle both; for a page that has
simply moved within your site, permanent is the right end state.

A safety net we'd add next time: a second rule from the mistaken path
(`/register`) to the right target, so anyone whose browser cached the bad
redirect still lands in the right place.

## A bug we found on the way

In our site's proxy, "preserve query string" *replaced* the target's query
with the visitor's. A visitor with no parameters would have arrived with no
parameters at all, and the rule's `utm_source` tag would have been silently
dropped. A test caught it before the rule went live; the proxy now merges
the two, visitor's values first. If your redirect targets carry tags, check
one with an untagged link.

## Renaming a page creates its own redirect

You don't need a rule every time a URL changes. When you change a page's
slug in glokki, the CMS tries to add a redirect from the old slug for you.
It skips that when another page still uses the old slug or a redirect from
it already exists, so check the result rather than assuming.

## Checklist

- Full URL for targets outside your site.
- Temporary while testing; permanent only when final.
- Regex with anchors (`^…$`) so the rule doesn't match more than you meant.
- Test with and without a query string.
- Unpublish the old page once the redirect works.

[Create a free account](https://app.glokki.com/register?utm_source=blog&utm_campaign=redirects-utm)
and manage redirects from the dashboard.

# More from the blog

## Manage redirects from the dashboard

Free during beta · No credit card required

[Create free account](https://app.glokki.com/register?utm_source=blog&utm_campaign=redirects-utm)

[Read the docs](https://glokki.com/docs)

---

Source: https://glokki.com/blog/whats-new-october-2026

# What's new in glokki: October 2026

glokki team · Oct 26, 2026

Self-serve sign-up, 15 developer guides with Markdown versions for agents, SDK fixes for caching and analytics, and a blog with RSS.

A monthly round-up of what changed in glokki. This is the first one.

## Create an account yourself

You no longer have to request access and wait for us to set up a workspace.
[Create an account](https://app.glokki.com/register?utm_source=blog&utm_campaign=whats-new-2026-10)
and you get your own organization straight away. glokki is free during the
beta.

Old links to the access-request form now redirect to sign-up.

## Developer documentation

[glokki.com/docs](/docs) now has 15 guides, covering integration with
Next.js and troubleshooting, blocks and layouts, the editor preview route,
the delivery and write APIs, the MCP server, page analytics, the asset CDN
proxy, sitemaps, and caching with PostgreSQL.

Every guide is also available as Markdown for agents — append `.md` to the
URL, or send `Accept: text/markdown` — and the whole site is listed in
[`/llms.txt`](/llms.txt) and [`/llms-full.txt`](/llms-full.txt).

## SDK fixes

- **`@glokki-cms/sdk` 0.17:** the asset and OG-image proxies now send
  `CDN-Cache-Control`, so images are cached at the edge on Vercel instead of
  running a function on every request. If you added your own wrapper for
  this, you can remove it.
- **`@glokki-cms/react` 0.10:** `<PageAnalytics />` no longer counts views
  from the visual editor or preview links, so editing a page doesn't
  inflate its numbers.

## A blog, with RSS

You're reading it. The [blog](/blog) is built the same way a glokki
customer would build one — a collection and two blocks — and has an
[RSS feed](/blog/rss.xml). The first posts:

- [We rebuilt glokki.com with an AI agent and our own CMS](/blog/rebuilt-glokki-com-with-an-ai-agent)
- [How we added a blog to our Next.js site with glokki collections](/blog/adding-a-blog-with-glokki-collections)
- [How to make your website discoverable by AI](/blog/make-your-site-discoverable-by-ai)
- [Edit your website from Claude Code with the glokki MCP server](/blog/edit-your-website-from-claude-code)
- [Redirects and UTM tags without touching code](/blog/redirects-and-utm-without-code)

# More from the blog

## Try what's new

Free during beta · No credit card required

[Create free account](https://app.glokki.com/register?utm_source=blog&utm_campaign=whats-new-2026-10)

[Read the docs](https://glokki.com/docs)

---

Source: https://glokki.com/contact-us

Contact

# Talk to the people building Glokki.

Questions about the beta, multi-tenant setups for your agency, a data processing agreement or a bug in the SDK — send us a message and a person will answer.

- **Reply within one business day** Our typical response time during the beta.
- **DPAs on request** The editor runs in a single EU region. Ask for a data processing agreement here.
- **Stuck integrating?** The most common SDK issues are covered in the [troubleshooting guide](https://glokki.com/docs/troubleshooting)

## Faster answers

Most questions are already covered here.

- [Pricing, self-hosting, GDPR](https://glokki.com/#faq) — Free during beta, where your data lives and how tenants are isolated.
- [Integrate with Next.js](https://glokki.com/docs/integration) — Install the SDK, register blocks and render your first page.
- [Connect an AI agent](https://glokki.com/docs/mcp-server) — Let Claude or any MCP client edit and publish pages.

---

Source: https://glokki.com/docs

# Glokki documentation

Integrate the SDK with Next.js, build custom blocks and layouts, use the content APIs and automate editing with the MCP server.

## Start here

1. [Integrate with Next.js](https://glokki.com/docs/integration) — Install @glokki-cms/sdk and @glokki-cms/react and render your first page.
2. [Build your first block](https://glokki.com/docs/block-development) — defineBlock, a Zod schema, your React component — editable in the visual editor.
3. [Connect an AI agent](https://glokki.com/docs/mcp-server) — Let Claude or any MCP client create pages, add blocks and publish.

```bash
npm i @glokki-cms/sdk @glokki-cms/react zod
```

**Reading this with an agent?** Append `.md` to any docs URL, or fetch [llms-full.txt](/llms-full.txt) for everything at once.

---

Source: https://glokki.com/docs/api-reference

# API Reference

Reference for the primary public exports from @glokki-cms/sdk and @glokki-cms/react.

For step-by-step integration instructions, see the [Integration Guide](/docs/integration). For block development patterns, see the [Block Development Guide](/docs/block-development).

---

## Table of Contents

### `@glokki-cms/sdk`

1. [`defineBlock()`](#defineblock)
2. [`createBlockRegistry()`](#createblockregistry)
3. [`createClient()`](#createclient)
4. [`CMSError`](#cmserror)
5. [`serializeBlockSchema()`](#serializeblockschema)

### `@glokki-cms/react`

6. [`VisualEditorProvider`](#visualeditorprovider)
7. [`useVisualEditor()`](#usevisualeditor)
8. [`CMSPageEditor`](#cmspageeditor)
9. [`CMSPage`](#cmspage)
10. [`PreviewBanner`](#previewbanner)
11. [`cmsMetadata()`](#cmsmetadata)
12. [`JsonLd`](#jsonld)
13. [`FormBlock`](#formblock)
14. [`CdnImage`](#cdnimage)
15. [`createComponentRegistry()`](#createcomponentregistry) _(deprecated)_

### Types

16. [SDK Types](#sdk-types)
17. [React Types](#react-types)

---

## `@glokki-cms/sdk`

### `defineBlock()`

The primary SDK API for block registration. Declares a block with metadata, a Zod schema, and a React component. Auto-serializes the Zod schema into `FieldDescriptor[]` so the CMS never touches Zod directly.

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

#### Signature

```ts
function defineBlock<TSchema, TComponent>(
  config: DefineBlockConfig<TSchema, TComponent>
): BlockRegistryEntry<InferOutput<TSchema>, TComponent>
```

#### `DefineBlockConfig`

| Property           | Type                                              | Required | Description                                                                                                                                                                                                                                                 |
| ------------------ | ------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`             | `string`                                          | Yes      | Unique block type key (e.g., `"hero"`, `"rich-text"`)                                                                                                                                                                                                       |
| `label`            | `string`                                          | Yes      | Human-readable display name                                                                                                                                                                                                                                 |
| `category`         | `BlockCategory` (a `string`)                      | Yes      | Category key this block is grouped under. Any key without whitespace, 1–64 characters. Must appear in the registry's `categories` option when one is declared; otherwise use one of the CMS built-ins (`content`, `layout`, `media`, `interactive`, `list`) |
| `icon`             | `string`                                          | Yes      | Lucide icon name for the component library browser                                                                                                                                                                                                          |
| `description`      | `string`                                          | No       | Short description shown in the component library browser                                                                                                                                                                                                    |
| `defaultProps`     | `InferOutput<TSchema>`                            | Yes      | Default props when adding a new instance (type-safe, inferred from schema)                                                                                                                                                                                  |
| `propsSchema`      | `TSchema`                                         | Yes      | Zod `z.object()` schema describing the block's props                                                                                                                                                                                                        |
| `viewportDefaults` | `ViewportOverrides<TProps>`                       | No       | Per-viewport prop defaults (`{ tablet?: Partial<TProps>, mobile?: Partial<TProps> }`)                                                                                                                                                                       |
| `slots`            | `SlotDefinition[] \| (props) => SlotDefinition[]` | No       | Slot definitions for blocks that can contain child blocks. Static array or a function that derives slots from current props                                                                                                                                 |
| `component`        | `TComponent`                                      | Yes      | React component that renders the block                                                                                                                                                                                                                      |

#### Returns

A `BlockRegistryEntry` with pre-serialized `fields: FieldDescriptor[]`, ready for `createBlockRegistry()`.

#### Example

```ts
import { z } from "zod"
import { defineBlock } from "@glokki-cms/sdk"
import { HeroComponent } from "./hero"

const heroSchema = z.object({
  heading: z.string().min(1).max(120).describe("Heading"),
  subheading: z.string().max(240).optional().describe("Subheading"),
  alignment: z.enum(["left", "center", "right"]).default("center"),
})

export const HeroBlock = defineBlock({
  type: "hero",
  label: "Hero",
  category: "content",
  icon: "Sparkles",
  defaultProps: {
    heading: "Hello World",
    alignment: "center",
  },
  propsSchema: heroSchema,
  component: HeroComponent,
})
```

#### Errors

Throws if `propsSchema` is not a `z.object()` schema (e.g., `z.record()`, `z.string()`). Only `z.object()` schemas can be serialized into field descriptors.

---

### `createBlockRegistry()`

Creates a typed block registry from an array of `defineBlock()` results. The registry provides component lookup, wire-safe serialized definitions for the iframe handshake, and type introspection.

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

#### Signature

```ts
function createBlockRegistry(
  entries: BlockRegistryEntry[],
  options?: BlockRegistryOptions
): BlockRegistry
```

#### `BlockRegistryOptions`

| Property     | Type                        | Required | Description                                                                                                                                               |
| ------------ | --------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `categories` | `BlockCategoryDefinition[]` | No       | The tenant's own ordered `{ key, label }` block categories. Omit it to keep the CMS's built-in five (`content`, `layout`, `media`, `interactive`, `list`) |

A declared list **replaces** the CMS's built-in categories — it never merges
with them — and the block picker renders the categories in the array's order.
Keys and labels must be non-empty and at most 64 characters, and a key may not
contain whitespace. Every block's `category` must appear in the list;
`createBlockRegistry` throws at build time otherwise.

#### `BlockRegistry`

| Method                       | Returns                                           | Description                                                                                                                                             |
| ---------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `getComponent(type)`         | `unknown \| undefined`                            | Look up the rendering component for a block type                                                                                                        |
| `getSerializedDefinitions()` | `SerializedBlockDefinition[]`                     | Wire-safe definitions for the iframe handshake (no Zod, no React)                                                                                       |
| `getTypes()`                 | `string[]`                                        | All registered block type strings (e.g., `["hero", "cta"]`)                                                                                             |
| `getCategories()`            | `readonly BlockCategoryDefinition[] \| undefined` | The categories declared via `options.categories`, in declaration order, or `undefined` when none were declared. Sent to the CMS on the iframe handshake |
| `resolveSlots(type, props)`  | `SlotDefinition[] \| undefined`                   | Resolve slot definitions for a block given its current props. Evaluates dynamic slot functions                                                          |
| `hasDynamicSlots(type)`      | `boolean`                                         | Returns `true` if a block's slots are defined as a function of props                                                                                    |

#### Example

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

const HeroBlock = defineBlock({ type: "hero" /* ... */ })
const CtaBlock = defineBlock({ type: "cta" /* ... */ })

const registry = createBlockRegistry([HeroBlock, CtaBlock], {
  categories: [
    { key: "content", label: "Content" },
    { key: "interactive", label: "Interactive" },
  ],
})

registry.getTypes() // ["hero", "cta"]
registry.getComponent("hero") // HeroComponent
registry.getSerializedDefinitions() // SerializedBlockDefinition[]
registry.getCategories() // the two declared categories, in order
```

#### Errors

Throws if duplicate block types are detected in the `entries` array, and — when
`categories` is declared — if the list is empty, holds a duplicate key, has an
invalid key or label, or omits a category that one of the blocks names.

---

### `createClient()`

Creates a typed client for the CMS Delivery API. Supports fetching pages, navigation, redirects, A/B experiment resolution, and collections.

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

#### Signature

```ts
function createClient(options: ClientOptions): CMSClient
```

#### `ClientOptions`

| Property       | Type          | Required | Description                                                                                      |
| -------------- | ------------- | -------- | ------------------------------------------------------------------------------------------------ |
| `baseUrl`      | `string`      | Yes      | Base URL of the CMS API (e.g., `"http://localhost:3000"`)                                        |
| `apiKey`       | `string`      | No       | API key for authentication (sent as `Authorization: Bearer` header)                              |
| `tenantSlug`   | `string`      | No       | Tenant slug (sent as `X-Tenant-Slug` header)                                                     |
| `preview`      | `boolean`     | No       | Enable preview mode to fetch draft content                                                       |
| `previewToken` | `string`      | No       | Preview token for draft content access                                                           |
| `fetchInit`    | `RequestInit` | No       | Default `RequestInit` merged into every fetch call. Controls framework-level caching (see below) |

The `fetchInit` option lets you control Next.js caching behavior:

```ts
// Always fresh (disables ISR/SSG)
createClient({ baseUrl, fetchInit: { cache: "no-store" } })

// On-demand revalidation via tags
createClient({ baseUrl, fetchInit: { next: { tags: ["cms"] } } })

// ISR with 60-second TTL
createClient({ baseUrl, fetchInit: { next: { revalidate: 60 } } })
```

#### `CMSClient`

The client object returned by `createClient()` exposes these namespaces:

##### `client.pages`

| Method                   | Returns                         | Description                                                                                       |
| ------------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------- |
| `getBySlug(slug, opts?)` | `Promise<DeliveryPage \| null>` | Fetch a published page by slug. Returns `null` for 404. Supports `locale` and `visitorId` options |
| `list(opts?)`            | `Promise<{ data, meta }>`       | List published pages. Returns `DeliveryPageListItem[]` with `PaginationMeta`                      |

##### `client.navigation`

| Method       | Returns                     | Description                                                      |
| ------------ | --------------------------- | ---------------------------------------------------------------- |
| `get(opts?)` | `Promise<NavigationItem[]>` | Fetch navigation items (slug, title, locale) for published pages |

##### `client.redirects`

| Method   | Returns                       | Description                     |
| -------- | ----------------------------- | ------------------------------- |
| `list()` | `Promise<DeliveryRedirect[]>` | Fetch all active redirect rules |

##### `client.experiments`

| Method                                           | Returns                              | Description                                                                                                                     |
| ------------------------------------------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| `resolveVariant(experimentId, visitorId, opts?)` | `Promise<VariantResolution \| null>` | Resolve which A/B variant a visitor should see. Deterministic assignment. Pass `{ recordImpression: true }` to track atomically |
| `recordImpression(experimentId, visitorId)`      | `Promise<void>`                      | Record an impression. Do NOT use after `pages.getBySlug()` (it already records impressions)                                     |
| `recordConversion(experimentId, visitorId)`      | `Promise<void>`                      | Record a conversion event for an experiment                                                                                     |

##### `client.collections`

| Method                     | Returns                               | Description                                                                                                      |
| -------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `list(opts?)`              | `Promise<{ data, meta }>`             | List all collections for the tenant                                                                              |
| `getBySlug(slug)`          | `Promise<DeliveryCollection \| null>` | Fetch a collection by slug. Returns `null` for 404                                                               |
| `listEntries(slug, opts?)` | `Promise<{ entries, pagination }>`    | List published entries. Supports pagination, sorting, tag filtering, locale filtering, and dynamic field filters |

##### `ListCollectionEntriesOptions`

| Property        | Type                     | Description                                                                                             |
| --------------- | ------------------------ | ------------------------------------------------------------------------------------------------------- |
| `page`          | `number`                 | Page number (default: 1)                                                                                |
| `limit`         | `number`                 | Entries per page (max: 100)                                                                             |
| `sortBy`        | `string`                 | Sort field: `"published_at"`, `"created_at"`, `"updated_at"`, `"title"`, or a sortable custom field key |
| `sortDirection` | `"asc" \| "desc"`        | Sort direction                                                                                          |
| `locale`        | `string`                 | Filter by locale                                                                                        |
| `tag`           | `string`                 | Filter by tag slug                                                                                      |
| `fields`        | `Record<string, string>` | Dynamic field filters (e.g., `{ author: "John" }`)                                                      |

**Field filters.** `fields` (or `?field.<key>=<value>` on the raw endpoint) filters on custom fields that are sortable, shown in the list, or filterable. Text fields match partially, select/boolean exactly, and date/number fields accept `<key>_gte` / `<key>_lte`. Data model reference fields accept a record UUID **or a record key** (`{ topics: "seo" }`); a multi reference (`dataModelRefs`) matches entries that contain the record. A key that matches no record returns an empty list rather than ignoring the filter. Repeating a parameter on the raw endpoint (`?field.topics=seo&field.topics=performance`) requires all of them.

#### Example

```ts
const cms = createClient({
  baseUrl: process.env.CMS_BASE_URL!,
  tenantSlug: process.env.CMS_TENANT!,
  apiKey: process.env.CMS_API_KEY!,
  fetchInit: { next: { tags: ["cms-pages"] } },
})

// Fetch a page
const page = await cms.pages.getBySlug("about")

// List pages
const { data: pages, meta } = await cms.pages.list({ limit: 10 })

// Collections
const { entries } = await cms.collections.listEntries("blog", {
  page: 1,
  limit: 10,
  sortBy: "published_at",
  sortDirection: "desc",
  tag: "featured",
})
```

---

### `CMSError`

Error class thrown when the Delivery API returns an error response.

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

#### Properties

| Property  | Type     | Description                                                         |
| --------- | -------- | ------------------------------------------------------------------- |
| `code`    | `string` | Machine-readable error code (e.g., `"NOT_FOUND"`, `"UNAUTHORIZED"`) |
| `message` | `string` | Human-readable error message                                        |
| `status`  | `number` | HTTP status code                                                    |

#### Example

```ts
try {
  // Note: pages.getBySlug() returns null for 404, not an error.
  // CMSError is thrown for non-404 errors like auth failures.
  const nav = await cms.navigation.get()
} catch (error) {
  if (error instanceof CMSError) {
    console.log(error.code) // "UNAUTHORIZED"
    console.log(error.status) // 401
  }
}
```

---

### `serializeBlockSchema()`

Low-level utility that converts a Zod `z.object()` schema into `FieldDescriptor[]`. Used internally by `defineBlock()` — you typically don't need to call this directly.

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

#### Signature

```ts
function serializeBlockSchema(schema: ZodObjectSchema): FieldDescriptor[]
```

---

## `@glokki-cms/react`

### `VisualEditorProvider`

Context provider that manages visual editor state. In normal mode, renders the `fallbackTree` with zero overhead. In editor mode (when loaded inside the CMS iframe), initiates a postMessage handshake with the CMS and syncs the component tree, selection, and viewport in real-time.

```tsx
import { VisualEditorProvider } from "@glokki-cms/react"
```

#### Props (`VisualEditorProviderProps`)

| Prop             | Type                | Required | Description                                                                                                                                                                                                                                                                                       |
| ---------------- | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `registry`       | `ComponentRegistry` | Yes      | Component registry mapping block types to React components. Should be a stable reference (module-level or memoized)                                                                                                                                                                               |
| `blockRegistry`  | `BlockRegistry`     | No       | Typed block registry from `createBlockRegistry()`. Enables v2 handshake with full block definitions and dynamic slot updates                                                                                                                                                                      |
| `layoutRegistry` | `LayoutRegistry`    | Yes      | Layout registry from `createLayoutRegistry()`. Serialized into the handshake and used to render header/footer chrome around the block tree                                                                                                                                                        |
| `fallbackTree`   | `ComponentNode[]`   | No       | Component tree for non-editor mode. Typically from the Delivery API (default: `[]`)                                                                                                                                                                                                               |
| `pageSlug`       | `string`            | No       | Page slug sent in the handshake (e.g., `"/about"`)                                                                                                                                                                                                                                                |
| `tenantSlug`     | `string`            | No       | Tenant slug this app serves (e.g., `process.env.NEXT_PUBLIC_CMS_TENANT`). Enables the cross-tenant guard: on a mismatch with the editing session's tenant the CMS shows a connection error and the provider withholds its `ready` reply, so no draft content is exchanged. Omit to skip the check |
| `children`       | `ReactNode`         | Yes      | Child components                                                                                                                                                                                                                                                                                  |

#### Behavior

- **Normal mode**: Uses `fallbackTree` as the component tree. No postMessage bridge overhead.
- **Editor mode** (detected via `?_editor=1&_editorOrigin=...` query params): Initiates handshake with the CMS, then receives `tree:update`, `block:select`, `viewport:change`, `block:props-update`, `block:scroll-into-view`, and `token:refresh` messages.

#### Example

```tsx
<VisualEditorProvider
  registry={registry}
  blockRegistry={blockRegistry}
  layoutRegistry={layoutRegistry}
  fallbackTree={page.componentTree}
  pageSlug="/about"
  tenantSlug={process.env.NEXT_PUBLIC_CMS_TENANT}
>
  <CMSPageEditor />
</VisualEditorProvider>
```

---

### `useVisualEditor()`

Hook that reads the visual editor context. Must be called within a `<VisualEditorProvider>`.

```tsx
import { useVisualEditor } from "@glokki-cms/react"
```

#### Signature

```ts
function useVisualEditor(): VisualEditorContextValue
```

#### `VisualEditorContextValue`

| Property          | Type                | Description                                                                                |
| ----------------- | ------------------- | ------------------------------------------------------------------------------------------ |
| `componentTree`   | `ComponentNode[]`   | Current component tree (from editor or fallback)                                           |
| `selectedBlockId` | `string \| null`    | ID of the currently selected block                                                         |
| `viewport`        | `Viewport`          | Current viewport: `"desktop"` \| `"tablet"` \| `"mobile"`                                  |
| `isEditorMode`    | `boolean`           | Whether the page is loaded inside the CMS editor iframe                                    |
| `registry`        | `ComponentRegistry` | The component registry passed to the provider                                              |
| `editorOrigin`    | `string \| null`    | Origin of the CMS editor window, or `null` when not in editor mode                         |
| `isLibraryOpen`   | `boolean`           | Whether the block library panel is open in the CMS sidebar                                 |
| `isTreeSynced`    | `boolean`           | `true` after the first `tree:update` message is received. Always `true` in non-editor mode |
| `editorToken`     | `string \| null`    | Current editor session token (refreshed periodically). `null` when not in editor mode      |

#### Example

```tsx
function MyBlock() {
  const { isEditorMode, selectedBlockId, viewport } = useVisualEditor()

  return (
    <div>
      {isEditorMode && <span>Editing</span>}
      {viewport === "mobile" && <MobileLayout />}
    </div>
  )
}
```

#### Errors

Throws if called outside a `<VisualEditorProvider>`.

---

### `CMSPageEditor`

Editor-aware page renderer. Reads the component tree from `VisualEditorContext`, wraps each block in `<EditableBlock>` for click/hover selection, and renders `<InsertionIndicator>` and `<BlockGapInserter>` for visual insertion feedback.

```tsx
import { CMSPageEditor } from "@glokki-cms/react"
```

#### Props

None. Reads all state from `VisualEditorContext`.

#### Behavior

- Renders each block using the component registry with interactive editing wrappers
- Shows an empty canvas placeholder when the tree is empty (editor mode only)
- Renders insertion indicators at the computed insertion position when the block library is open
- Reports `tree:rendered` back to the CMS after each render cycle

#### Example

```tsx
<VisualEditorProvider registry={registry} pageSlug="/about">
  <CMSPageEditor />
</VisualEditorProvider>
```

---

### `CMSPage`

Static page renderer for non-editor contexts. Renders a component tree using a registry without any editor interactivity. Use this for published page rendering.

```tsx
import { CMSPage } from "@glokki-cms/react"
```

#### Props

| Prop            | Type                | Required | Description                                      |
| --------------- | ------------------- | -------- | ------------------------------------------------ |
| `componentTree` | `ComponentNode[]`   | Yes      | The component tree from published page data      |
| `registry`      | `ComponentRegistry` | Yes      | Registry mapping block types to React components |

#### Example

```tsx
<CMSPage componentTree={page.componentTree} registry={registry} />
```

---

### `PreviewBanner`

Amber warning banner displayed at the top of the viewport during preview mode. Shows "Preview mode --- this page is not published" with a dismiss button. Uses inline styles so tenant apps don't need Tailwind or any CSS framework.

```tsx
import { PreviewBanner } from "@glokki-cms/react"
```

#### Props

None.

#### Example

```tsx
export default async function Page({ searchParams }) {
  const { preview } = await searchParams
  const isPreview = preview === "true"
  return (
    <>
      {isPreview && <PreviewBanner />}
      <MainContent />
    </>
  )
}
```

---

### `cmsMetadata()`

Generates a Next.js `Metadata` object from a CMS page's SEO data. Handles title, description, canonical URL, robots, Open Graph, Twitter Card, and hreflang alternates.

```ts
import { cmsMetadata } from "@glokki-cms/react"
```

#### Signature

```ts
function cmsMetadata(
  page: DeliveryPage | null,
  options?: CmsMetadataOptions
): Metadata
```

#### `CmsMetadataOptions`

| Property  | Type     | Description                                                                                                                                                                                                    |
| --------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `baseUrl` | `string` | Base URL of the tenant's site (e.g., `"https://acme.editor.app"`). Used for canonical URL and Open Graph URL generation                                                                                        |
| `env`     | `string` | Runtime environment (default: `process.env.APP_ENV`). Any value other than `"production"` — **including unset** — forces `robots: { index: false, follow: false }`, overriding the page's own robots directive |

#### Behavior

- Returns `{ title: "Page not found", robots: { index: false } }` when `page` is `null`
- **Non-production is always `noindex, nofollow`.** Unless `env` (or `APP_ENV`) is exactly `"production"`, robots are forced to `{ index: false, follow: false }` so staging and preview deploys are never indexed. Set `APP_ENV=production` on your production deployment, or every page will be de-indexed. In production, the page's stored robots string is honoured (`"none"` = `noindex, nofollow`)
- JSON-LD is **not** included (Next.js Metadata API does not support it). Use the `<JsonLd>` component separately

#### Example

```tsx
import { cmsMetadata, JsonLd } from "@glokki-cms/react"

export async function generateMetadata({ params }) {
  const { slug } = await params
  const page = await cms.pages.getBySlug(slug)
  return cmsMetadata(page, { baseUrl: "https://acme.editor.app" })
}

export default async function Page({ params }) {
  const { slug } = await params
  const page = await cms.pages.getBySlug(slug)
  return (
    <>
      <JsonLd data={page.seo.jsonLd} />
      <CMSPage componentTree={page.componentTree} registry={registry} />
    </>
  )
}
```

---

### `JsonLd`

Renders JSON-LD structured data as `<script type="application/ld+json">` tags. Escapes closing script tags to prevent XSS. Each item in the array becomes a separate script tag.

```tsx
import { JsonLd } from "@glokki-cms/react"
```

#### Props

| Prop   | Type                                               | Required | Description                                                                                   |
| ------ | -------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------- |
| `data` | `SeoMetadata["jsonLd"] \| Record<string, unknown>` | Yes      | JSON-LD objects from the page's SEO data. Accepts an array or a single object (legacy format) |

Renders nothing when `data` is `undefined` or an empty array, so you can pass `page.seo.jsonLd` without a guard.

#### Example

```tsx
<JsonLd data={page.seo.jsonLd} />
```

---

### `FormBlock`

Client component that renders a form built in the CMS (**Forms**), validates it in the browser, and submits it to the Delivery API. Field definitions, the success message, the redirect URL, and spam protection (honeypot, optional Turnstile/reCAPTCHA) come from the form's settings in the CMS.

```tsx
import { FormBlock } from "@glokki-cms/react"
```

#### Props

| Prop              | Type                                           | Required | Description                                                                                             |
| ----------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------- |
| `client`          | `FormBlockClient`                              | Yes      | A delivery client from `createClient()` (anything with `forms.getById` and `forms.submit`). Memoize it. |
| `formId`          | `string`                                       | Yes      | The published form's id.                                                                                |
| `initialForm`     | `DeliveryForm`                                 | No       | The form definition, fetched on the server, so the form renders without a client-side request.          |
| `onSubmitSuccess` | `(response: { submissionId: string }) => void` | No       | Called after a successful submission, before the redirect (if the form has one).                        |
| `onSubmitError`   | `(error: unknown) => void`                     | No       | Called with the raw error when submission fails.                                                        |
| `submitLabel`     | `string`                                       | No       | Submit button text. Default `"Submit"`. Since 0.10.0.                                                   |
| `submittingLabel` | `string`                                       | No       | Button text while the submission is in flight. Default `"Submitting…"`. Since 0.10.0.                   |

Validation messages and the loading text are English and not yet configurable.

#### Styling

The component renders unstyled markup with stable hooks: the root carries `data-form-block` and `data-status` (`loading`, `ready`, `submitting`, `success`, `error`); the button has `data-form-submit`, and a submission error is a `data-form-error` element with `role="alert"`.

#### Example

The form fetches and submits from the browser, so the client needs a key there: use a delivery key (`delivery:read` scope), which is meant to be public, and keep admin keys server-side.

```tsx
"use client"

import { FormBlock } from "@glokki-cms/react"
import { createClient } from "@glokki-cms/sdk"

const client = createClient({
  baseUrl: process.env.NEXT_PUBLIC_CMS_BASE_URL!,
  tenantSlug: process.env.NEXT_PUBLIC_CMS_TENANT!,
  apiKey: process.env.NEXT_PUBLIC_CMS_API_KEY!,
})

export function ContactForm({ formId }: { formId: string }) {
  return (
    <FormBlock
      client={client}
      formId={formId}
      submitLabel="Send message"
      submittingLabel="Sending…"
    />
  )
}
```

---

### `CdnImage`

Renders an image prop served through your `/cdn/` route with a responsive `srcSet` and the asset's focal point. Any non-`/cdn/` URL renders as a plain `<img>`. Since 0.10.0. Props and the generated markup are described in [Asset CDN Proxy](/docs/asset-cdn-proxy#rendering-with-a-cdnimage-component).

```tsx
import { CdnImage } from "@glokki-cms/react"

;<CdnImage
  src={image}
  alt={alt}
  width={1200}
  height={800}
  sizes="(min-width: 768px) 50vw, 100vw"
/>
```

---

### `createComponentRegistry()`

> **Deprecated.** Use `createBlockRegistry()` from `@glokki-cms/sdk` instead.

Creates a simple `Map<string, Component>` from a plain object. Does not support the v2 handshake, block metadata, or dynamic slots.

```ts
import { createComponentRegistry } from "@glokki-cms/react"
```

#### Signature

```ts
function createComponentRegistry(
  components: Record<string, ComponentType<Record<string, unknown>>>
): ComponentRegistry
```

#### Migration

```ts
// Before (deprecated)
const registry = createComponentRegistry({
  hero: HeroComponent,
  cta: CtaComponent,
})

// After (recommended)
import { defineBlock, createBlockRegistry } from "@glokki-cms/sdk"

const HeroBlock = defineBlock({ type: "hero", ..., component: HeroComponent })
const CtaBlock = defineBlock({ type: "cta", ..., component: CtaComponent })
const registry = createBlockRegistry([HeroBlock, CtaBlock])
```

---

## Additional Exports

The packages export several additional utilities and components beyond the primary APIs documented above. These are typically used for advanced integrations or internal purposes.

### `@glokki-cms/sdk` — Additional

| Export                                         | Description                                                                      |
| ---------------------------------------------- | -------------------------------------------------------------------------------- |
| `sendMessage(target, type, payload, origin)`   | Send a typed postMessage to the CMS or iframe                                    |
| `onMessage(origin, handler)`                   | Listen for typed postMessages from a specific origin. Returns a cleanup function |
| `createMessage(type, payload, seq?, version?)` | Create a protocol-stamped message envelope                                       |
| `isVisualEditorMessage(value)`                 | Runtime type guard for visual editor messages                                    |
| `buildEditorIframeUrl(params)`                 | Construct the iframe `src` URL for the visual editor                             |
| `generateCollectionTypes(options)`             | Generate TypeScript types for a single collection                                |
| `generateAllCollectionTypes(options)`          | Generate TypeScript types for all tenant collections                             |
| `SDK_VERSION`                                  | SDK version string sent in the handshake request                                 |

### `@glokki-cms/react` — Additional

| Export                | Description                                                    |
| --------------------- | -------------------------------------------------------------- |
| `EditableBlock`       | Wrapper component for click/hover selection in editor mode     |
| `BlockGapInserter`    | Click-to-insert button rendered between blocks in editor mode  |
| `InsertionIndicator`  | Visual indicator showing where the next block will be inserted |
| `VisualEditorContext` | Raw React context (use `useVisualEditor()` hook instead)       |
| `SDK_VERSION`         | SDK version constant                                           |

---

## SDK Types

Key types exported from `@glokki-cms/sdk`:

### Block Types

| Type                                     | Description                                                                                                  |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `BlockRegistryEntry<TProps, TComponent>` | Result of `defineBlock()` with pre-serialized field descriptors                                              |
| `DefineBlockConfig<TSchema, TComponent>` | Configuration object passed to `defineBlock()`                                                               |
| `BlockCategory`                          | A block's category key — a free-form `string`, not a closed union (the tenant declares its own set)          |
| `BlockCategoryDefinition`                | One declared category: `{ key: string, label: string }`                                                      |
| `SerializedBlockCategory`                | Wire form of a category — identical to `BlockCategoryDefinition`                                             |
| `BlockRegistryOptions`                   | Second argument to `createBlockRegistry()`: `{ categories?: BlockCategoryDefinition[] }`                     |
| `BlockRegistry`                          | Registry interface returned by `createBlockRegistry()`                                                       |
| `SerializedBlockDefinition`              | Wire-safe block definition sent during iframe handshake                                                      |
| `SlotDefinition`                         | Named slot area where child blocks can be dropped                                                            |
| `SerializedSlotDefinition`               | Wire-safe slot definition (static or dynamic)                                                                |
| `BlockViewportOverrides<TProps>`         | Per-viewport prop overrides for block definitions (`{ tablet?: Partial<TProps>, mobile?: Partial<TProps> }`) |
| `ViewportOverrides`                      | Non-generic viewport overrides for delivery API wire types                                                   |

### Delivery API Types

| Type                   | Description                                                                                   |
| ---------------------- | --------------------------------------------------------------------------------------------- |
| `ClientOptions`        | Options for `createClient()`                                                                  |
| `CMSClient`            | Client type returned by `createClient()`                                                      |
| `DeliveryPage`         | Published page with component tree, SEO, code injection                                       |
| `DeliveryPageListItem` | Lightweight page item from the list endpoint                                                  |
| `ComponentNode`        | A node in the component tree (`id`, `type`, `props`, `children`, `slot`, `viewportOverrides`) |
| `SeoMetadata`          | SEO fields: meta title, description, canonical, robots, OG, Twitter, JSON-LD                  |
| `CodeInjection`        | Custom `<head>` and `<body>` code snippets                                                    |
| `NavigationItem`       | Published page navigation entry (`slug`, `title`, `locale`)                                   |
| `DeliveryRedirect`     | Redirect rule (`source`, `target`, `type`, `isRegex`, `preserveQueryString`)                  |
| `PaginationMeta`       | Pagination metadata (`total`, `limit`, `offset`)                                              |
| `ApiError`             | Error response shape from the Delivery API                                                    |
| `AlternateLocale`      | Alternate locale link for hreflang (`locale`, `url`)                                          |
| `ExperimentMeta`       | A/B experiment metadata (`experimentId`, `variantId`, `variantName`, `isControl`)             |
| `VariantResolution`    | Variant resolution result (extends `ExperimentMeta` with `pageId`)                            |

### Collection Types

| Type                           | Description                                                                                                                                                                                                                |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DeliveryCollection`           | Collection with fields schema, sort config, and URL prefix                                                                                                                                                                 |
| `CollectionEntry`              | Published collection entry with `collectionData`, SEO, and tags                                                                                                                                                            |
| `CollectionEntryTag`           | Tag attached to an entry (`id`, `name`, `slug`)                                                                                                                                                                            |
| `CollectionEntriesPagination`  | Pagination for entries (`page`, `limit`, `total`, `totalPages`)                                                                                                                                                            |
| `CollectionFieldDefinition`    | Field definition within a collection schema                                                                                                                                                                                |
| `CollectionFieldType`          | Field types: `"text"`, `"textarea"`, `"richtext"`, `"number"`, `"date"`, `"datetime"`, `"boolean"`, `"url"`, `"image"`, `"file"`, `"select"`, `"multiselect"`, `"relation"`, `"dataModelRef"`, `"dataModelRefs"`, `"json"` |
| `ListCollectionEntriesOptions` | Options for listing collection entries                                                                                                                                                                                     |

### Field Descriptor Types

| Type              | Description                                         |
| ----------------- | --------------------------------------------------- |
| `FieldDescriptor` | Serialized field definition for the CMS props panel |
| `FieldType`       | Field type string used in descriptors               |
| `FieldValidation` | Validation rules (min, max, pattern, etc.)          |
| `JsonValue`       | JSON-safe value type                                |

### Visual Editor Protocol Types

| Type                  | Description                                        |
| --------------------- | -------------------------------------------------- | --- |
| `VisualEditorMessage` | Base message type for CMS <-> iframe communication |
| `ProtocolVersion`     | Protocol version number (`1                        | 2`) |
| `CMSToIframeMessage`  | Union of all message types from CMS to iframe      |
| `IframeToCMSMessage`  | Union of all message types from iframe to CMS      |
| `Viewport`            | `"desktop" \| "tablet" \| "mobile"`                |

### Protocol Constants

| Export                      | Value             | Description                                                  |
| --------------------------- | ----------------- | ------------------------------------------------------------ |
| `PROTOCOL_VERSION`          | `2`               | Current protocol version (numeric)                           |
| `SUPPORTED_VERSIONS`        | `[1, 2]`          | Supported protocol versions (numeric array)                  |
| `NAMESPACE`                 | `"__editorCms"`   | Message namespace guard — filters foreign postMessage events |
| `EDITOR_QUERY_PARAM`        | `"_editor"`       | URL param that enables editor mode                           |
| `EDITOR_ORIGIN_QUERY_PARAM` | `"_editorOrigin"` | URL param containing the CMS origin                          |
| `EDITOR_TOKEN_QUERY_PARAM`  | `"_editorToken"`  | URL param containing the editor session token                |
| `EDITOR_LOCALE_QUERY_PARAM` | `"locale"`        | URL param for locale override inside the editor iframe       |

---

## React Types

Key types exported from `@glokki-cms/react`:

| Type                        | Description                                                    |
| --------------------------- | -------------------------------------------------------------- |
| `VisualEditorProviderProps` | Props for `<VisualEditorProvider>`                             |
| `VisualEditorContextValue`  | Return type of `useVisualEditor()`                             |
| `ComponentRegistry`         | `Map<string, ComponentType>` mapping block types to components |
| `CmsMetadataOptions`        | Options for `cmsMetadata()`                                    |
| `EditableBlockProps`        | Props for the `<EditableBlock>` wrapper (internal)             |
| `BlockGapInserterProps`     | Props for `<BlockGapInserter>` (internal)                      |
| `InsertionIndicatorProps`   | Props for `<InsertionIndicator>` (internal)                    |

---

Source: https://glokki.com/docs/asset-cdn-proxy

# Asset CDN Proxy

How tenant-uploaded media flows from the CMS to your tenant app — and why every published tenant app needs a /cdn/\[...path\] route.

The CMS stores uploaded files (images, video, documents) in S3-compatible object storage. Published pages reference those files through your tenant's own origin under `/cdn/...` paths instead of the underlying storage URL. The `/cdn/` route is a thin proxy back to the CMS Delivery API — `@glokki-cms/sdk/asset-proxy` provides it as a drop-in.

---

## Table of Contents

1. [Why This Exists](#1-why-this-exists)
2. [The URL Pipeline](#2-the-url-pipeline)
3. [Quickstart](#3-quickstart)
4. [Required Environment Variables](#4-required-environment-variables)
5. [What `createAssetProxy()` Does](#5-what-createassetproxy-does)
6. [Query Parameters](#6-query-parameters)
7. [OG Image Proxy](#7-og-image-proxy)
8. [Verifying the Setup](#8-verifying-the-setup)
9. [Troubleshooting](#9-troubleshooting)

---

## 1. Why This Exists

The CMS dashboard and your tenant app run on **different origins** (e.g. `cms.editor.app` and `acme.com`). Uploaded assets live in object storage at a third origin (e.g. `s3.example.com`, `cdn.digitaloceanspaces.com`, or `localhost:9000` for MinIO).

If published HTML referenced the storage URL directly, three problems follow:

- **Storage layout leaks** — every page exposes the bucket name and tenant ID prefix (`tenants/{uuid}/...`).
- **Cache headers belong to S3, not your CDN** — you cannot control caching, set custom `Cache-Control`, or invalidate via your edge.
- **Cross-origin requests** — every `<img>` triggers a separate connection to the storage host, with its own CORS rules and TLS handshake.

The fix: tenant pages reference assets under their own origin (`/cdn/foo.jpg`), and a tiny route handler proxies those requests back to the CMS Delivery API, which streams from storage with full HTTP semantics preserved.

---

## 2. The URL Pipeline

```
┌─────────────────────────────────────────────────────────────────┐
│ CMS dashboard (origin A: cms.editor.app)                        │
│                                                                 │
│ Picker → http://s3.example.com/bucket/tenants/{id}/foo.jpg      │
│          (absolute URL, used so the preview image loads in the  │
│           props panel from an origin the dashboard can reach)   │
│                                                                 │
│ Component tree stored in DB with the absolute URL.              │
│                                                                 │
│ Delivery API (read-time only):                                  │
│ Walks the whole props tree, rewrites every URL pointing at      │
│ tenant storage to `/cdn/foo.jpg?w&h&fp` (dimensions +           │
│ object-position).                                               │
└─────────────────────────────────────────────────────────────────┘
                              │
                              │ Delivery API response carries
                              │ `src: "/cdn/foo.jpg"`
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│ Tenant Next.js app (origin B: acme.com)                         │
│                                                                 │
│ Renders <img src="/cdn/foo.jpg" />.                             │
│ Browser issues GET acme.com/cdn/foo.jpg.                        │
└─────────────────────────────────────────────────────────────────┘
                              │
                              │ Same origin — hits the proxy route
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│ app/cdn/[...path]/route.ts                                      │
│                                                                 │
│ createAssetProxy() forwards the request to                      │
│ `${CMS_BASE_URL}/api/v1/delivery/assets/foo.jpg`                │
│ with `Authorization: Bearer ${CMS_API_KEY}` and                 │
│ `X-Tenant-Slug: ${CMS_TENANT}`.                                 │
│                                                                 │
│ CMS resolves the tenant, looks up the asset, streams it from    │
│ object storage, and returns headers + body.                     │
│ Range and ETag headers are passed through both                  │
│ directions, so partial-content (206) and conditional (304)      │
│ requests work end-to-end.                                       │
└─────────────────────────────────────────────────────────────────┘
```

The crucial insight: **the URL rewrite from absolute to `/cdn/...` is a read-time transform inside the CMS**, applied on every fetch of the component tree. Nothing is persisted as `/cdn/...` in the database — the absolute URL stays canonical, the relative form is derived.

This means:

- The CMS dashboard's props panel shows the absolute URL right after a picker selection. It only collapses to `/cdn/...` on the next SSR load of the editor page (after autosave + refresh).
- The Delivery API always serves the `/cdn/...` form to consumers.
- Changing an asset's focal point or regenerating variants takes effect on the next read with no re-save of the page itself, because the derived data is rebuilt every time.

---

## 3. Quickstart

Add a single route file to your tenant Next.js app:

```ts
// app/cdn/[...path]/route.ts
import { createAssetProxy } from "@glokki-cms/sdk/asset-proxy"

const handler = createAssetProxy()

export const GET = handler
export const HEAD = handler
```

That's it. The factory reads `CMS_BASE_URL`, `CMS_TENANT`, and `CMS_API_KEY` from `process.env` and returns an async route handler.

For non-Next frameworks or multi-tenant setups, pass the options explicitly:

```ts
const handler = createAssetProxy({
  baseUrl: "https://cms.editor.app",
  tenantSlug: "acme",
  apiKey: process.env.CMS_API_KEY,
})
```

---

## 4. Required Environment Variables

| Variable       | Required | Description                                                                                                        |
| -------------- | -------- | ------------------------------------------------------------------------------------------------------------------ |
| `CMS_BASE_URL` | Yes      | URL of the CMS deployment (e.g. `http://localhost:3000`, `https://cms.editor.app`). Trailing slashes are stripped. |
| `CMS_TENANT`   | Yes      | Your tenant slug. Sent as the `X-Tenant-Slug` header so the CMS resolves the correct tenant.                       |
| `CMS_API_KEY`  | Yes      | Delivery API key (CMS Dashboard → Settings → API Keys). Sent as `Authorization: Bearer ...`.                       |

These are the **same three variables** used by the SDK's `createClient()` — if you already wired up the integration in [Integration Guide](/docs/integration), you don't need new secrets.

Since `@glokki-cms/sdk` 0.17.0 the proxy reads them **on each request**, not when the route module loads. `next build` loads every route module during its "Collecting page data" step, so the build no longer needs the variables. A request that finds one missing gets a `500` and a server log line such as `[asset-proxy] createAssetProxy: baseUrl is required (set CMS_BASE_URL or pass opts.baseUrl)`.

> **Older SDKs (before 0.17.0)** check the variables when `createAssetProxy()` is called. Called at the top level of the route module, as in the Quickstart, that fails `next build` with `createAssetProxy: baseUrl is required ...` whenever the build environment lacks the variables (common in CI). Upgrade, or make the variables available to the build step.

---

## 5. What `createAssetProxy()` Does

For each `GET` or `HEAD` request to `/cdn/<path>`:

1. Joins the `[...path]` segments into a filename, percent-encoding each one so names containing `#`, `?` or `%` survive the hop.
2. Builds `${CMS_BASE_URL}/api/v1/delivery/assets/${filename}`.
3. Forwards `Authorization` and `X-Tenant-Slug` headers.
4. Forwards the client's `Range` and `If-None-Match` headers so partial-content (206) and conditional (304) responses pass through.
5. Streams the response body back to the browser without buffering.
6. Whitelists response headers (`content-type`, `content-length`, `content-range`, `cache-control`, `etag`, `accept-ranges`, `last-modified`, `x-content-type-options`) — everything else is dropped.
7. Adds `CDN-Cache-Control` to immutable `200` responses (see [Caching](#caching)).

On upstream failure (network error, DNS miss), the proxy returns `502 Bad Gateway`. Other HTTP statuses (404 for unknown assets, 401 for bad API key) pass through unchanged.

### Caching

The CMS decides the caching policy and the proxy passes its `Cache-Control` header through unchanged:

| Response                                                                                     | `Cache-Control` from the CMS          |
| -------------------------------------------------------------------------------------------- | ------------------------------------- |
| Normal asset (original or variant)                                                           | `public, max-age=31536000, immutable` |
| Degraded response — the CMS could not resolve a `?w` variant and served the original instead | `public, max-age=60`                  |

Asset URLs are content-addressed — a file is never overwritten under the same path — so a one-year immutable cache is safe. The short degraded policy exists so the proper variant replaces the heavier original soon after the CMS recovers.

The CMS sends `max-age` only, which most CDNs placed in front of your app (Cloudflare, Fastly) use for their edge cache too. Some don't: **Vercel's CDN does not cache a function response on `max-age` alone** — it needs `s-maxage`, `CDN-Cache-Control` or `Vercel-CDN-Cache-Control`. So the proxy copies the CMS's `Cache-Control` into `CDN-Cache-Control` ([RFC 9213](https://www.rfc-editor.org/rfc/rfc9213)) on full `200` responses whose policy is `immutable`. Partial `206` responses, `304`s, errors and the short-lived degraded responses get no `CDN-Cache-Control`, so a fallback is never pinned at the edge for longer than the CMS intended. You do not need to configure anything.

### Hosting on Vercel with an SDK older than 0.17.0

Older proxies forward `Cache-Control` only, so on Vercel every `/cdn/` request runs your route and fetches the file from the CMS. Upgrade `@glokki-cms/sdk`, or wrap the handler:

```ts
// lib/with-cdn-cache.ts
import type { AssetProxyHandler } from "@glokki-cms/sdk/asset-proxy"

/** Mirrors an immutable 200's Cache-Control into CDN-Cache-Control. */
export function withCdnCache(handler: AssetProxyHandler): AssetProxyHandler {
  return async (request, ctx) => {
    const response = await handler(request, ctx)
    const cacheControl = response.headers.get("cache-control")
    if (response.status !== 200 || !cacheControl?.includes("immutable")) {
      return response
    }
    const headers = new Headers(response.headers)
    headers.set("CDN-Cache-Control", cacheControl)
    return new Response(response.body, { status: response.status, headers })
  }
}
```

and export `withCdnCache(createAssetProxy())` (and `withCdnCache(createOgProxy())` for [OG images](#7-og-image-proxy)) as `GET` and `HEAD`.

---

## 6. Query Parameters

The Delivery API rewrites **every** image URL in a block's props to a `/cdn/`
path — regardless of the prop's name, whether it sits in an array, or how
deeply it is nested inside arrays of objects. Detection is by URL shape: any
string pointing at `tenants/{id}/...` storage is rewritten. There is no
prop-name convention to follow.

### What gets rewritten to `/cdn/`

| Part of the delivery response                                | Rewritten? | Notes                                                                                                                                                                                                            |
| ------------------------------------------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Block props (`componentTree[].props`, at any depth)          | Yes        | Any string whose **entire value** is a tenant storage URL (or an existing `/cdn/` path). A string containing whitespace or `<` — e.g. a rich-text/HTML prop that merely embeds an asset URL — is left untouched. |
| Per-viewport overrides (`viewportOverrides`)                 | No         | Not rewritten yet — see [Troubleshooting](#storage-url-appears-un-rewritten).                                                                                                                                    |
| Layout header/footer data (`layout.header`, `layout.footer`) | Yes        | Same rule as block props, e.g. a logo `src` in your header.                                                                                                                                                      |
| `collectionData` — `image` and `file` fields                 | Yes        | These fields store an **asset ID**, which is resolved to the same `/cdn/<file>?w&h&fp` URL. If the asset no longer exists, the raw ID is returned.                                                               |
| `collectionData` — every other field                         | No         | Passed through as stored.                                                                                                                                                                                        |
| `seo.og.image`                                               | No         | The generated social card is an absolute `/og/` URL (see [OG Image Proxy](#7-og-image-proxy)). An image set manually by an editor is returned exactly as stored.                                                 |
| `seo.twitter.image`                                          | No         | Returned exactly as stored.                                                                                                                                                                                      |

### Derived data in the query string

Each rewritten URL carries derived data in its query string:

| Param | Meaning                              | Consumed by                               |
| ----- | ------------------------------------ | ----------------------------------------- |
| `w`   | The asset's natural width in pixels  | the `/cdn/` endpoint, to select a variant |
| `h`   | The asset's natural height in pixels | the block, for layout stability           |
| `fp`  | Focal point as `x,y` fractions (0–1) | the block, as CSS `object-position`       |

### Rendering with a `<CdnImage>` component

`@glokki-cms/react` (0.10.0 and later) exports `CdnImage`. Use it in any block that renders an image prop. For a `/cdn/` URL it builds a responsive `srcSet` by re-stamping `?w`, and applies `?fp` as `object-position`. Any other URL (a placeholder, an external image) renders as a plain `<img>`.

```tsx
// blocks/hero.tsx
import { CdnImage } from "@glokki-cms/react"

export function Hero({ image, title }: { image: string; title: string }) {
  return (
    <section className="relative h-[60vh]">
      <CdnImage src={image} alt={title} fill sizes="100vw" loading="eager" />
    </section>
  )
}
```

| Prop                 | Description                                                                                  |
| -------------------- | -------------------------------------------------------------------------------------------- |
| `src`, `alt`         | Required. `src` is the prop value from the CMS.                                              |
| `width`, `height`    | Reserve layout space. Ignored with `fill`.                                                   |
| `fill`               | Absolutely positions the image to fill its (positioned) parent.                              |
| `sizes`              | Passed to the `<img>`; defaults to `100vw` for `/cdn/` URLs.                                 |
| `loading`            | `"lazy"` (default) or `"eager"`.                                                             |
| `format`             | `"webp"` or `"avif"`, stamped as `?f` on every candidate. Omitted, the endpoint serves WebP. |
| `className`, `style` | Passed through; a focal point is merged into `style` as `object-position`.                   |

For `src="/cdn/hero.jpg?w=2400&h=1600&fp=0.25,0.75"` it renders:

- `src="/cdn/hero.jpg?w=1920"`
- `srcset="/cdn/hero.jpg?w=480 480w, /cdn/hero.jpg?w=1024 1024w, /cdn/hero.jpg?w=1920 1920w"`
- `style="object-position: 25.00% 75.00%"`

Pass the asset's natural dimensions (the `w` and `h` values from the URL) or your block's own dimensions as `width`/`height` to reserve layout space, and pass `sizes` whenever the image is narrower than the viewport — the default `100vw` makes the browser pick the widest variant. For a hero image that is the page's largest element, pass `loading="eager"` so it is not lazy-loaded.

Variants are served as WebP by default. Pass `format="avif"` to serve AVIF instead — only if every browser you target supports AVIF, because there is no automatic negotiation (see [Format selection](#format-selection--f) below).

### Variant selection — `?w`

`GET /cdn/foo.jpg?w=800` makes the delivery endpoint stream the smallest
pre-generated variant at least 800px wide (or the widest, if none is larger).
Without `?w`, the original file is streamed unchanged. A request for an asset
with no processed variants falls back to the original — a missing variant is
never a 404.

### Format selection — `?f`

`?f` picks the variant format: `?f=avif` for AVIF, anything else (or absent)
for WebP. The format lives in the URL — and therefore in the cache key — so no
`Vary: Accept` header is needed. There is no automatic per-browser
negotiation; `?f=avif` is an explicit opt-in.

---

## 7. OG Image Proxy

Every published page gets a generated social card — the image crawlers show when the page is shared — and the Delivery API returns its URL in `seo.og.image`. By default that URL points at the CMS origin. To serve cards from your own domain instead, add a second route next to `/cdn/`:

```ts
// app/og/[...path]/route.ts
import { createOgProxy } from "@glokki-cms/sdk/og-proxy"

const handler = createOgProxy()

export const GET = handler
export const HEAD = handler
```

`createOgProxy()` works exactly like `createAssetProxy()`:

- It reads the **same three variables** — `CMS_BASE_URL`, `CMS_TENANT`, `CMS_API_KEY` — or takes `{ baseUrl, tenantSlug, apiKey }` explicitly. No new secrets.
- It reads them per request (SDK 0.17.0+), so `next build` doesn't need them — see [Required Environment Variables](#4-required-environment-variables).
- It forwards `If-None-Match`, streams the PNG back, passes through the same response-header allowlist (including `Cache-Control`, plus `CDN-Cache-Control` on immutable cards), and returns `502` when the CMS is unreachable. Upstream failures are logged with the prefix `[og-proxy]`.

### Turning it on in the CMS

Deploying the route is not enough — the CMS keeps emitting CMS-origin URLs until you switch it over:

1. Make sure your site's address is set in **CMS Dashboard → Settings → Visual Editor → Frontend URL**. The tenant-domain URL is built from it; while it is empty, cards keep being served from the CMS origin even with the toggle on.
2. Deploy the `/og/[...path]` route above.
3. In **CMS Dashboard → Settings → Social Cards**, turn on **Serve social cards from your own domain** (in the _Delivery domain_ card). The card previews the host that will appear in `og:image`.

| Toggle               | `seo.og.image` in the delivery response                |
| -------------------- | ------------------------------------------------------ |
| Off (default)        | `https://<cms-host>/og/<tenantId>/<pageId>/<hash>.png` |
| On, Frontend URL set | `https://<your-frontend-url>/og/<pageId>/<hash>.png`   |
| On, no Frontend URL  | Same as Off                                            |

Both forms are **absolute** URLs, as `og:image` requires — unlike asset URLs, they are not `/cdn/`-relative. The tenant-domain form carries no tenant ID; the proxy's API key identifies the tenant.

Two cases where the generated card is not used:

- **A manual image wins.** If an editor set an OG image on the page, `seo.og.image` is that image, returned exactly as stored, and no card URL is emitted.
- **Preview responses** (`?preview=true&token=...`) do not include a generated card.

### Caching

`<hash>` is derived from everything that goes into the card (template, title and other text, background image, locale), so editing the page or its template produces a **new URL** rather than new bytes at the old one. A successfully generated card is therefore served with `Cache-Control: public, max-age=31536000, immutable`. If generation falls back to a degraded render, the response gets `public, max-age=60` instead, so the real card replaces it soon.

The proxy adds `CDN-Cache-Control` to immutable cards exactly as it does for assets, so Vercel's CDN caches them; a degraded card is left to expire.

---

## 8. Verifying the Setup

1. **Upload an asset** through the CMS dashboard's media library.
2. **Add a block** with an image field and pick the upload.
3. **Save and reload** the page editor. The props panel value should now read `/cdn/<filename>?w&h…` instead of an absolute URL. (Reload is required — enrichment is read-time only.)
4. **Open your tenant app** at the published page's URL. Inspect the rendered `<img>` — the `src` attribute should be `/cdn/<filename>?w…` and the `<img>` should carry a `srcset`.
5. **Open DevTools → Network** and load the image. The request should hit `/cdn/...` on your tenant origin, return `200 OK`, and carry `Cache-Control: public, max-age=31536000, immutable` from the CMS.
6. **Confirm your CDN caches it.** Request the same URL twice and check the cache-status header your CDN adds. The response should carry `CDN-Cache-Control` with the same value, and on Vercel the second request should show `x-vercel-cache: HIT`:

   ```bash
   curl -sI "https://acme.com/cdn/foo.jpg?w=1024" | grep -i -E "cache-control|x-vercel-cache"
   curl -sI "https://acme.com/cdn/foo.jpg?w=1024" | grep -i x-vercel-cache
   # Expect on the second request: x-vercel-cache: HIT
   ```

   `MISS` on every request with no `CDN-Cache-Control` header means an SDK older than 0.17.0 — upgrade, or see [the wrapper](#hosting-on-vercel-with-an-sdk-older-than-0170). A response with `Cache-Control: public, max-age=60` is a degraded response and is deliberately not cached long.

7. **Check the social card** (if you set up the [OG image proxy](#7-og-image-proxy)). Fetch a published page from the Delivery API and open its `seo.og.image` URL: it should start with your frontend URL, return `200` with `Content-Type: image/png`, and — on Vercel — show `x-vercel-cache: HIT` on the second request.
8. **Test range support** (optional, mostly for video):

   ```bash
   curl -I -H "Range: bytes=0-1023" https://acme.com/cdn/big.mp4
   # Expect: 206 Partial Content
   # Content-Range: bytes 0-1023/<total>
   ```

9. **Test conditional GET** (optional):

   ```bash
   curl -I -H 'If-None-Match: "<etag-from-previous-response>"' https://acme.com/cdn/foo.jpg
   # Expect: 304 Not Modified
   ```

---

## 9. Troubleshooting

### `404` on `/cdn/...` requests

Either the route file is missing, or Next.js hasn't picked it up. Check:

- `app/cdn/[...path]/route.ts` exists at the project root, not inside a route group.
- The file exports `GET` (and ideally `HEAD`).
- The dev server has restarted since the file was added.

### `502 Bad Gateway`

The proxy reached your tenant app but the upstream fetch to `CMS_BASE_URL` failed. Common causes:

- `CMS_BASE_URL` points at an unreachable host (e.g. a Docker-internal name from a non-Docker dev server).
- The CMS is not running.
- TLS misconfiguration on the CMS host.

Check the tenant app server logs — `createAssetProxy()` logs upstream fetch errors to `console.error` with the prefix `[asset-proxy]`.

### `401 Unauthorized` from upstream

`CMS_API_KEY` is invalid or revoked, or `CMS_TENANT` doesn't match a tenant in the CMS. Regenerate the key in **CMS Dashboard → Settings → API Keys** and confirm the slug.

### Absolute storage URL appears in the editor

You're looking at the editor immediately after picking an image. The picker writes an absolute URL into the working tree; enrichment runs only at SSR load. Save (autosave will fire) and reload the editor — the value should collapse to `/cdn/...`.

If it's still absolute after a reload, the CMS couldn't match the URL to an asset in your tenant's media library. Check:

- The asset was uploaded to **this** tenant's media library — URLs pointing at another tenant's storage are left untouched.
- The URL contains `tenants/{tenantId}/...` — only URLs of that shape are recognised as tenant storage.

### Storage URL appears un-rewritten

Only URLs containing `tenants/{tenantId}/...` are recognised as tenant storage. If the URL does not match (e.g. it is already a relative path or an external CDN URL), the CMS skips it by design.

Values set as per-viewport overrides (tablet/mobile) are not rewritten yet — an image picked only in a viewport override ships its absolute storage URL. Set the image on the base (desktop) props instead.

### Social card URL points at the CMS, or `/og/...` returns `404`

- `seo.og.image` still uses the CMS host: the **Serve social cards from your own domain** toggle is off, or no Frontend URL is set in **Settings → Visual Editor**.
- `/og/...` on your site returns `404` for every page: the `app/og/[...path]/route.ts` file is missing (or sits inside a route group). Turning the toggle on before the route is deployed breaks every shared link preview until it is.
- `404` for one page only: the hash in the URL is stale — the page or its template changed since the URL was fetched. Re-fetch the page from the Delivery API for the current URL.

### Range requests return `200 OK` instead of `206`

The `accept-ranges` header is in the passthrough whitelist, but if your CDN/edge strips it, the browser won't send range requests. Confirm `accept-ranges: bytes` is present on the response from your tenant origin (not just from the CMS).

---

## Related Guides

- [Integration Guide](/docs/integration) — overall SDK setup, including `CMS_BASE_URL` / `CMS_TENANT` / `CMS_API_KEY` env vars.
- [Block Development](/docs/block-development) — schema-to-editor field mapping, including `fieldType: "image"`.

---

Source: https://glokki.com/docs/asset-import-api

# Asset Import API

Upload files into your tenant's media library over HTTP — and organize them into folders that are created on the fly. Built for scripts, CI jobs, and migration tooling that need to push assets without a browser session.

This guide covers `POST /api/v1/admin/import/asset`. For how those uploaded files are later *served* to a published site, see the [Asset CDN Proxy Guide](/docs/asset-cdn-proxy). For the SDK delivery client, see the [Integration Guide](/docs/integration).

---

## Table of Contents

1. [What it does](#what-it-does)
2. [Prerequisites](#prerequisites)
3. [Endpoint](#endpoint)
4. [Folder paths](#folder-paths)
5. [Examples](#examples)
6. [Supported file types and limits](#supported-file-types-and-limits)
7. [Error handling](#error-handling)
8. [FAQ](#faq)

---

## What it does

`POST /api/v1/admin/import/asset` accepts a single file as `multipart/form-data` and stores it in your tenant's media library — the same library you see at `/media` in the dashboard. It is authenticated with a **Bearer API key** (scope `admin:assets:write`) rather than a browser session, so it can run headless.

The endpoint optionally takes a **`folderPath`** field. When you provide one, the API resolves it to a folder, **creating any missing folders along the way**, and places the uploaded asset in the leaf folder. This lets a single request both build folder structure and upload into it.

---

## Prerequisites

1. **An API key with the `admin:assets:write` scope.** Create one in the CMS Dashboard → **Settings → API Keys**. Keys look like `ek_live_...`.
2. **Your tenant slug**, sent as the `X-Tenant-Slug` header (optional but recommended; it is validated against the key's tenant).
3. The base URL of your CMS instance (e.g. `https://cms.example.com` or `http://localhost:3000`).

Store these as environment variables, mirroring the rest of the platform's tooling:

```bash
CMS_BASE_URL=https://cms.example.com
CMS_API_KEY=ek_live_...
CMS_TENANT=acme
```

---

## Endpoint

```
POST /api/v1/admin/import/asset
```

### Headers

| Header | Required | Description |
|---|---|---|
| `Authorization` | Yes | `Bearer ek_live_...` — an API key with the `admin:assets:write` scope. |
| `X-Tenant-Slug` | Optional | Your tenant slug. Validated against the key's tenant when present. |

### Body (`multipart/form-data`)

| Field | Type | Required | Description |
|---|---|---|---|
| `file` | File | Yes | The asset binary. |
| `folderPath` | string | No | A `/`-separated folder path, e.g. `Marketing/2026/Launch`. Missing folders are auto-created; the asset is placed in the leaf folder. Omit (or leave empty) to upload to the library root. |
| `alt` | string | No | Alt text for the asset. |
| `width` | number | No | Image width hint in pixels. |
| `height` | number | No | Image height hint in pixels. |

### Response

`201 Created`:

```json
{
  "success": true,
  "data": {
    "id": "a3f1…",
    "cdnUrl": "/assets/<contentHash>/<filename>",
    "contentHash": "<sha256>",
    "folderId": "f9c2…"
  }
}
```

- `id` — the new asset's UUID.
- `cdnUrl` — the content-addressed URL used when referencing the asset on a page. See the [Asset CDN Proxy Guide](/docs/asset-cdn-proxy) for how this is served.
- `contentHash` — SHA-256 of the uploaded bytes.
- `folderId` — the resolved leaf folder's UUID, or `null` when the asset was uploaded to the root.

---

## Folder paths

`folderPath` is split on `/`; each segment becomes one nesting level. Resolution is **find-or-create**:

- Each segment is matched within its parent by its **slug** (a normalized, lowercased form of the name). So `Marketing/2026` and `marketing/2026` resolve to the same folders, and a folder created here matches one created with the same name in the dashboard UI.
- Missing folders are created; existing ones are reused. Re-running the same import is **idempotent** — it will not create duplicate folders.
- Leading, trailing, and doubled slashes are ignored: `/Marketing//2026/` resolves to `Marketing` → `2026`.
- The maximum depth is **10** segments.

### Concurrency safety

Concurrent imports targeting the same path are safe: folder uniqueness is enforced at the database level by a `(tenant, parent, slug)` unique constraint with `NULLS NOT DISTINCT`, so two parallel requests creating the same folder (including a **root-level** folder) converge on one row rather than producing duplicates.

---

## Examples

### `curl`

Upload into an auto-created folder:

```bash
curl -X POST "$CMS_BASE_URL/api/v1/admin/import/asset" \
  -H "Authorization: Bearer $CMS_API_KEY" \
  -H "X-Tenant-Slug: $CMS_TENANT" \
  -F "file=@./hero.jpg" \
  -F "folderPath=Marketing/2026/Launch" \
  -F "alt=Spring launch hero"
```

Upload to the library root (no `folderPath`):

```bash
curl -X POST "$CMS_BASE_URL/api/v1/admin/import/asset" \
  -H "Authorization: Bearer $CMS_API_KEY" \
  -H "X-Tenant-Slug: $CMS_TENANT" \
  -F "file=@./logo.png"
```

### Node.js / TypeScript (`fetch`)

```ts
import { readFile } from "node:fs/promises"

async function importAsset(filePath: string, folderPath?: string) {
  const form = new FormData()
  const bytes = await readFile(filePath)
  form.append("file", new Blob([bytes]), filePath.split("/").pop())
  if (folderPath) form.append("folderPath", folderPath)

  const res = await fetch(`${process.env.CMS_BASE_URL}/api/v1/admin/import/asset`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.CMS_API_KEY}`,
      "X-Tenant-Slug": process.env.CMS_TENANT!,
    },
    body: form,
  })

  if (!res.ok) {
    const error = await res.json()
    throw new Error(`Upload failed: ${error.code} — ${error.error}`)
  }

  return (await res.json()).data as {
    id: string
    cdnUrl: string
    contentHash: string
    folderId: string | null
  }
}
```

### Bulk import

`folderPath` makes bulk imports a simple loop — no need to pre-create folders or track folder IDs. The first file that needs a folder creates it; later files reuse it:

```ts
const files = [
  { path: "./img/hero.jpg", folder: "Marketing/2026/Launch" },
  { path: "./img/banner.jpg", folder: "Marketing/2026/Launch" },
  { path: "./docs/spec.pdf", folder: "Marketing/2026/Docs" },
]

for (const f of files) {
  const asset = await importAsset(f.path, f.folder)
  console.log(`${f.path} → ${asset.cdnUrl} (folder ${asset.folderId})`)
}
```

---

## Supported file types and limits

- **Maximum file size:** 50 MB per file.
- **Allowed MIME types:**
  - **Images:** `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/avif`, `image/svg+xml`, `image/bmp`, `image/tiff`
  - **Video:** `video/mp4`, `video/webm`, `video/ogg`, `video/quicktime`
  - **Documents:** `application/pdf`, `application/msword`, `.docx`, `application/vnd.ms-excel`, `.xlsx`, `text/plain`, `text/csv`

Uploading an unsupported type returns `UNSUPPORTED_FILE_TYPE`. Images are queued for background processing (responsive variants, AI alt-text suggestions) after upload — the `201` response returns immediately and does not wait for that work.

---

## Error handling

All errors return `success: false` with a machine-readable `code`. HTTP status reflects the failure class.

| HTTP | `code` | Cause |
|---|---|---|
| 400 | `INVALID_BODY` | Request was not `multipart/form-data`. |
| 400 | `VALIDATION_ERROR` | Missing `file`, or invalid field (see `fieldErrors`). |
| 400 | `FILE_TOO_LARGE` | File exceeds the 50 MB limit. |
| 400 | `UNSUPPORTED_FILE_TYPE` | MIME type not in the allowed list. |
| 400 | `INVALID_FOLDER_NAME` | A `folderPath` segment has no alphanumeric characters (e.g. `"!!!"`). |
| 400 | `FOLDER_PATH_TOO_DEEP` | `folderPath` exceeds 10 segments. |
| 401 | — | Missing, invalid, expired, or revoked API key. |
| 403 | — | Valid key without the `admin:assets:write` scope. |
| 404 | `TENANT_NOT_FOUND` | The key's tenant is missing or inactive. |

---

## FAQ

**Does re-uploading the same file create a duplicate?**
Each `POST` creates a new asset record. Because `cdnUrl` and `contentHash` are derived from the file bytes, identical bytes always yield the same `cdnUrl`. If you need to avoid duplicate records, check `POST /api/v1/admin/import/find-asset-by-hash` before uploading.

**Can I move an asset to a different folder later?**
This endpoint only creates. Use the dashboard, or the session-authenticated folder/move endpoints, to reorganize existing assets.

**What if a folder name and an existing one differ only in case or spacing?**
They are matched by slug, so `Launch`, `launch`, and `Launch ` all resolve to the same folder.

---

Source: https://glokki.com/docs/block-development

# 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

---

Source: https://glokki.com/docs/custom-component-development

# Custom Component Development

Advanced patterns for building custom block types beyond the standard set.

This guide covers advanced block development techniques: container blocks with slots, viewport-responsive props, conditional rendering, and styling integration. For the basics of creating blocks, see the [Block Development Guide](/docs/block-development) first.

---

## Table of Contents

1. [When to Build Custom Blocks](#1-when-to-build-custom-blocks)
2. [Container Blocks with Slots](#2-container-blocks-with-slots)
3. [Dynamic Slots](#3-dynamic-slots)
4. [Rendering Slot Contents](#4-rendering-slot-contents)
5. [Viewport-Responsive Props](#5-viewport-responsive-props)
6. [Conditional Rendering](#6-conditional-rendering)
7. [Styling Integration](#7-styling-integration)
8. [Composition Patterns](#8-composition-patterns)
9. [Performance Considerations](#9-performance-considerations)
10. [Full Example: Tabs Block](#10-full-example-tabs-block)
11. [Checklist for Advanced Blocks](#11-checklist-for-advanced-blocks)

---

## 1. When to Build Custom Blocks

Standard blocks (Hero, CTA, Rich Text, Image) cover common landing page needs. Build a custom block when you need:

- **Layout composition** — blocks that contain other blocks (e.g., multi-column layouts, tabs, accordions)
- **Complex interactivity** — carousels, modals, expandable sections with client-side state
- **Domain-specific content** — pricing tables, comparison charts, team grids with custom data shapes
- **Responsive behavior** — blocks whose layout changes substantially across breakpoints
- **Brand-specific design** — components that consume your design system tokens

All custom blocks use the same `defineBlock()` + `createBlockRegistry()` pipeline. The advanced features covered here — slots, viewport defaults, conditional rendering — are built into the SDK and require no CMS-side changes.

---

## 2. Container Blocks with Slots

Slots let a block define named drop zones where editors can place child blocks. This is the foundation for layout blocks like columns, sections, tabs, and sidebars.

### Defining Static Slots

Pass a `slots` array to `defineBlock()`. Each slot is a `SlotDefinition` with a `name`, a `label`, and an optional `allowedTypes` filter:

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

const sidebarLayoutSchema = z.object({
  sidebarWidth: z
    .enum(["narrow", "medium", "wide"])
    .default("medium")
    .describe("Sidebar Width"),
  sidebarPosition: z
    .enum(["left", "right"])
    .default("left")
    .describe("Sidebar Position"),
})

export const SidebarLayoutBlock = defineBlock({
  type: "sidebar-layout",
  label: "Sidebar Layout",
  category: "layout",
  icon: "PanelLeft",
  defaultProps: {
    sidebarWidth: "medium",
    sidebarPosition: "left",
  },
  propsSchema: sidebarLayoutSchema,
  component: SidebarLayoutComponent,
  slots: [
    { name: "sidebar", label: "Sidebar" },
    {
      name: "content",
      label: "Content Area",
      allowedTypes: ["rich-text", "image", "cta", "features-grid"],
    },
  ],
})
```

### Slot Type Restrictions

Use `allowedTypes` to constrain which block types can be dropped into a slot:

```ts
slots: [
  {
    name: "header",
    label: "Header",
    allowedTypes: ["hero", "banner"], // Only Hero and Banner blocks
  },
  {
    name: "body",
    label: "Body",
    // No allowedTypes — any block type can be dropped here
  },
]
```

When `allowedTypes` is omitted, the slot accepts all registered block types. The CMS enforces these restrictions in the Component Library — disallowed types are greyed out when the slot is selected.

---

## 3. Dynamic Slots

When the number or configuration of slots depends on a prop value, pass a function instead of an array:

```ts
const columnsSchema = z.object({
  columns: z.number().min(1).max(6).default(3).describe("Number of Columns"),
  gap: z
    .enum(["none", "small", "medium", "large"])
    .default("medium")
    .describe("Column Gap"),
})

export const ColumnsBlock = defineBlock({
  type: "columns",
  label: "Columns",
  category: "layout",
  icon: "Columns3",
  defaultProps: { columns: 3, gap: "medium" },
  propsSchema: columnsSchema,
  component: ColumnsComponent,
  slots: (props) =>
    Array.from({ length: props.columns }, (_, i) => ({
      name: `column-${i}`,
      label: `Column ${i + 1}`,
    })),
})
```

### How Dynamic Slots Work

1. **At registration time** — `createBlockRegistry()` evaluates the slot function with `defaultProps` to produce the initial slot definitions for the CMS handshake.

2. **At edit time** — when the editor changes a prop that affects slot count (e.g., changing `columns` from 3 to 4), the SDK re-evaluates the slot function with the new props and the CMS updates its slot zones.

3. **Content preservation** — when reducing the number of slots (e.g., 4 columns to 2), blocks in removed slots are preserved in the component tree data but not rendered. Increasing the count again restores them.

### Dynamic Slots with Type Restrictions

```ts
slots: (props) =>
  Array.from({ length: props.columns }, (_, i) => ({
    name: `column-${i}`,
    label: `Column ${i + 1}`,
    allowedTypes: props.restrictedMode
      ? ["rich-text", "image"]
      : undefined,
  })),
```

---

## 4. Rendering Slot Contents

When a block has child blocks in its slots, the rendering system passes a `slotContents` prop to your component. This is a record mapping slot names to rendered React nodes:

```tsx
type SlotContents = Record<string, React.ReactNode>

type SidebarLayoutProps = z.infer<typeof sidebarLayoutSchema> & {
  slotContents?: SlotContents
}
```

### Basic Slot Rendering

```tsx
function SidebarLayoutComponent({
  sidebarWidth = "medium",
  sidebarPosition = "left",
  slotContents,
}: SidebarLayoutProps) {
  const widthClasses = {
    narrow: "w-48 lg:w-56",
    medium: "w-64 lg:w-72",
    wide: "w-80 lg:w-96",
  } as const

  const sidebar = (
    <aside className={`shrink-0 ${widthClasses[sidebarWidth]}`}>
      {slotContents?.sidebar}
    </aside>
  )

  const content = (
    <main className="min-w-0 flex-1">{slotContents?.content}</main>
  )

  return (
    <div className="flex gap-6 px-6 py-8">
      {sidebarPosition === "left" ? (
        <>
          {sidebar}
          {content}
        </>
      ) : (
        <>
          {content}
          {sidebar}
        </>
      )}
    </div>
  )
}
```

### Slot Rendering Rules

- **`slotContents` is injected automatically** — you do not need to declare it in the Zod schema. The rendering system adds it when the block has children.
- **Always check for undefined** — `slotContents` is undefined when the block has no children, and a specific slot key is undefined when that slot is empty.
- **Slots render as React nodes** — each slot value is a `ReactNode` (a single element or fragment). You place it directly in your JSX.
- **No direct child manipulation** — you cannot inspect or modify the child blocks. The slot is an opaque rendered output.

### Empty Slot Placeholder

Provide a visual placeholder for empty slots so editors know where to drop blocks:

```tsx
function SlotPlaceholder({ label }: { label: string }) {
  return (
    <div className="flex min-h-[80px] items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/20 p-4 text-sm text-muted-foreground">
      Drop blocks into {label}
    </div>
  )
}

function SidebarLayoutComponent({
  sidebarWidth = "medium",
  sidebarPosition = "left",
  slotContents,
}: SidebarLayoutProps) {
  const widthClasses = {
    narrow: "w-48 lg:w-56",
    medium: "w-64 lg:w-72",
    wide: "w-80 lg:w-96",
  } as const

  return (
    <div className="flex gap-6 px-6 py-8">
      <aside className={`shrink-0 ${widthClasses[sidebarWidth]}`}>
        {slotContents?.sidebar ?? <SlotPlaceholder label="Sidebar" />}
      </aside>
      <main className="min-w-0 flex-1">
        {slotContents?.content ?? <SlotPlaceholder label="Content" />}
      </main>
    </div>
  )
}
```

---

## 5. Viewport-Responsive Props

Use `viewportDefaults` to declare how a block's props should differ across breakpoints. These defaults are serialised into the block definition and sent to the CMS during the handshake. The CMS can use them to suggest different prop values when editors switch viewport preview modes (desktop, tablet, mobile).

### Declaring Viewport Defaults

```ts
export const PricingTableBlock = defineBlock({
  type: "pricing-table",
  label: "Pricing Table",
  category: "content",
  icon: "CreditCard",
  defaultProps: {
    columns: 3,
    showFeatureComparison: true,
    layout: "horizontal",
    cardSize: "large",
  },
  propsSchema: pricingTableSchema,
  component: PricingTableComponent,
  viewportDefaults: {
    tablet: {
      columns: 2,
      cardSize: "medium",
    },
    mobile: {
      columns: 1,
      showFeatureComparison: false,
      layout: "stacked",
      cardSize: "small",
    },
  },
})
```

### How Viewport Overrides Work

Viewport defaults declare the _intended_ prop values per breakpoint. They are shallow-merged onto the base props:

1. **Desktop** — the block renders with its base `defaultProps` (or the editor's current prop values)
2. **Tablet** — the `tablet` overrides are shallow-merged onto the base props
3. **Mobile** — the `mobile` overrides are shallow-merged onto the base props

Overrides are shallow — they replace top-level prop values, not nested object properties. For array props (like `features`), the entire array is replaced if overridden.

> **Note:** Viewport defaults are metadata — they tell the CMS what prop values are appropriate per breakpoint. Your component should also use CSS responsive classes (Tailwind breakpoints) for visual adaptations that don't require prop changes.

### Responsive Component Patterns

Design your component to respond to the props it receives, not to CSS media queries. This ensures the editor preview matches the actual behavior:

```tsx
type PricingTableProps = z.infer<typeof pricingTableSchema>

const COLUMN_CLASSES: Record<number, string> = {
  1: "grid-cols-1",
  2: "grid-cols-1 sm:grid-cols-2",
  3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
}

function PricingTableComponent({
  columns = 3,
  showFeatureComparison = true,
  layout = "horizontal",
  cardSize = "large",
  plans = [],
}: PricingTableProps) {
  const sizeClasses = {
    small: "p-4 text-sm",
    medium: "p-6 text-base",
    large: "p-8 text-lg",
  } as const

  return (
    <section className="px-6 py-12">
      <div className={`grid gap-6 ${COLUMN_CLASSES[columns] ?? "grid-cols-1"}`}>
        {plans.map((plan) => (
          <PricingCard
            key={plan.name}
            plan={plan}
            className={sizeClasses[cardSize]}
            showFeatures={showFeatureComparison}
          />
        ))}
      </div>
    </section>
  )
}
```

### Viewport Defaults vs CSS Media Queries

| Approach           | Use When                                                               |
| ------------------ | ---------------------------------------------------------------------- |
| `viewportDefaults` | Prop values should change (e.g., hide a section, reduce column count)  |
| CSS media queries  | Visual styling changes only (e.g., font size, padding, flex direction) |

Use both together: `viewportDefaults` for structural changes that the editor should preview, and CSS/Tailwind responsive classes for fine-grained visual adjustments.

---

## 6. Conditional Rendering

Conditional rendering lets editors toggle parts of a block on or off via props. This is more flexible than creating separate block types for each variation.

### Boolean Toggle Pattern

Add a boolean prop to control visibility of a section:

```ts
const heroSchema = z.object({
  heading: z.string().min(1).max(120).default("Welcome").describe("Heading"),
  showSubheading: z.boolean().default(true).describe("Show Subheading"),
  subheading: z.string().max(240).optional().describe("Subheading"),
  showCta: z.boolean().default(true).describe("Show CTA Button"),
  ctaLabel: z.string().max(60).optional().describe("CTA Label"),
  ctaUrl: z.string().url().optional().describe("CTA URL"),
  showBadge: z.boolean().default(false).describe("Show Badge"),
  badgeText: z.string().max(40).optional().describe("Badge Text"),
})
```

```tsx
function HeroComponent({
  heading = "Welcome",
  showSubheading = true,
  subheading,
  showCta = true,
  ctaLabel,
  ctaUrl,
  showBadge = false,
  badgeText,
}: HeroProps) {
  return (
    <section className="px-6 py-16 text-center">
      {showBadge && badgeText && (
        <span className="mb-4 inline-block rounded-full bg-accent/10 px-3 py-1 text-xs font-medium text-accent">
          {badgeText}
        </span>
      )}
      <h1 className="text-4xl font-bold">{heading}</h1>
      {showSubheading && subheading && (
        <p className="mt-4 text-lg text-muted-foreground">{subheading}</p>
      )}
      {showCta && ctaLabel && (
        <a
          href={ctaUrl ?? "#"}
          className="mt-6 inline-block rounded-md bg-primary px-6 py-2.5 text-primary-foreground"
        >
          {ctaLabel}
        </a>
      )}
    </section>
  )
}
```

### Enum Variant Pattern

Use an enum to switch between distinct layout variants:

```ts
const cardSchema = z.object({
  variant: z
    .enum(["simple", "detailed", "compact"])
    .default("simple")
    .describe("Card Variant"),
  title: z.string().min(1).max(100).default("Card Title").describe("Title"),
  description: z.string().max(300).optional().describe("Description"),
  image: z
    .string()
    .url()
    .optional()
    .meta({ fieldType: "image" })
    .describe("Image"),
  showMeta: z.boolean().default(false).describe("Show Metadata"),
  date: z.string().optional().describe("Date"),
  author: z.string().optional().describe("Author"),
})
```

```tsx
function CardComponent({
  variant = "simple",
  title = "Card Title",
  description,
  image,
  showMeta = false,
  date,
  author,
}: CardProps) {
  if (variant === "compact") {
    return (
      <div className="flex items-center gap-3 rounded-lg border p-3">
        {image && (
          <img src={image} alt="" className="h-12 w-12 rounded object-cover" />
        )}
        <div>
          <h3 className="font-medium">{title}</h3>
          {showMeta && date && (
            <span className="text-xs text-muted-foreground">{date}</span>
          )}
        </div>
      </div>
    )
  }

  if (variant === "detailed") {
    return (
      <article className="overflow-hidden rounded-xl border shadow-sm">
        {image && (
          <img
            src={image}
            alt=""
            className="aspect-video w-full object-cover"
          />
        )}
        <div className="p-6">
          <h3 className="text-xl font-bold">{title}</h3>
          {description && (
            <p className="mt-2 text-muted-foreground">{description}</p>
          )}
          {showMeta && (
            <div className="mt-4 flex gap-2 text-sm text-muted-foreground">
              {author && <span>{author}</span>}
              {date && <span>{date}</span>}
            </div>
          )}
        </div>
      </article>
    )
  }

  return (
    <div className="rounded-lg border p-4">
      <h3 className="font-semibold">{title}</h3>
      {description && (
        <p className="mt-1 text-sm text-muted-foreground">{description}</p>
      )}
    </div>
  )
}
```

### Conditional Rendering Guidelines

- **Always provide a fallback** — if a toggle hides all content, show at least the block wrapper so the editor can still select it.
- **Guard against undefined** — combine the toggle check with a value check: `{showCta && ctaLabel && ...}` prevents rendering an empty button.
- **Keep variants manageable** — if you have more than 4-5 variants, consider splitting into separate block types instead.
- **Document visible props** — use `.meta({ helpText: "..." })` to explain when each conditional field appears:
  ```ts
  badgeText: z
    .string()
    .max(40)
    .optional()
    .meta({ helpText: "Only visible when 'Show Badge' is enabled" })
    .describe("Badge Text"),
  ```

---

## 7. Styling Integration

Blocks render inside the tenant app's CSS context. The CMS iframe inherits the tenant's styles, so blocks should use the tenant's styling system.

### Tailwind CSS

Tailwind is the recommended approach. Use utility classes directly in your components:

```tsx
function FeatureSectionComponent({
  heading,
  features,
  bgColor = "default",
}: FeatureSectionProps) {
  const bgClasses = {
    default: "bg-background",
    muted: "bg-muted",
    accent: "bg-accent/5",
    dark: "bg-foreground text-background",
  } as const

  return (
    <section
      className={`px-6 py-16 ${bgClasses[bgColor] ?? bgClasses.default}`}
    >
      {heading && (
        <h2 className="mb-10 text-center text-2xl font-bold">{heading}</h2>
      )}
      <div className="mx-auto grid max-w-5xl gap-8 sm:grid-cols-2 lg:grid-cols-3">
        {features.map((feature) => (
          <div
            key={feature.title}
            className="rounded-lg border bg-card p-6 shadow-sm"
          >
            <h3 className="text-lg font-semibold">{feature.title}</h3>
            <p className="mt-2 text-sm text-muted-foreground">
              {feature.description}
            </p>
          </div>
        ))}
      </div>
    </section>
  )
}
```

### Using shadcn/ui Semantic Colors

The recommended color approach is to use shadcn/ui semantic color tokens (`bg-background`, `text-foreground`, `bg-card`, `text-muted-foreground`, etc.). These adapt to the tenant's theme and support dark mode automatically:

| Token                                    | Purpose                               |
| ---------------------------------------- | ------------------------------------- |
| `bg-background` / `text-foreground`      | Page background / primary text        |
| `bg-card` / `text-card-foreground`       | Card surfaces                         |
| `bg-muted` / `text-muted-foreground`     | Subdued backgrounds / secondary text  |
| `bg-primary` / `text-primary-foreground` | Primary brand color / text on primary |
| `bg-accent` / `text-accent-foreground`   | Accent color for highlights           |
| `border`                                 | Default border color                  |
| `bg-destructive`                         | Error / danger states                 |

### Inline Styles for Dynamic Values

Use inline styles only for values that come from props and cannot be expressed as utility classes:

```tsx
function DividerComponent({
  color = "#e5e7eb",
  thickness = 1,
  style = "solid",
}: DividerProps) {
  return (
    <hr
      className="my-8 border-0"
      style={{
        borderTopWidth: `${thickness}px`,
        borderTopStyle: style,
        borderTopColor: color,
      }}
    />
  )
}
```

### CSS Variables for Prop-Driven Theming

For blocks with multiple color props, use CSS custom properties to avoid scattering inline styles:

```tsx
function BannerComponent({
  bgColor = "#1e40af",
  textColor = "#ffffff",
  accentColor = "#fbbf24",
  heading,
  cta,
}: BannerProps) {
  return (
    <section
      className="px-6 py-12"
      style={
        {
          "--banner-bg": bgColor,
          "--banner-text": textColor,
          "--banner-accent": accentColor,
        } as React.CSSProperties
      }
    >
      <div
        className="mx-auto max-w-3xl text-center"
        style={{
          color: "var(--banner-text)",
          backgroundColor: "var(--banner-bg)",
        }}
      >
        <h2 className="text-2xl font-bold">{heading}</h2>
        {cta && (
          <a
            href={cta.url}
            className="mt-4 inline-block rounded-md px-5 py-2 font-medium"
            style={{
              backgroundColor: "var(--banner-accent)",
              color: "var(--banner-bg)",
            }}
          >
            {cta.label}
          </a>
        )}
      </div>
    </section>
  )
}
```

### Color Picker Fields

Expose color props using the `color` field type:

```ts
bgColor: z
  .string()
  .default("#1e40af")
  .meta({ fieldType: "color" })
  .describe("Background Color"),
```

The CMS renders a color picker in the PropsPanel for these fields.

---

## 8. Composition Patterns

### Wrapper Block Pattern

Create a generic wrapper block that adds spacing, background, or constraints around its children:

```ts
const sectionSchema = z.object({
  maxWidth: z
    .enum(["narrow", "default", "wide", "full"])
    .default("default")
    .describe("Max Width"),
  padding: z
    .enum(["none", "small", "medium", "large"])
    .default("medium")
    .describe("Padding"),
  background: z
    .enum(["transparent", "muted", "accent", "dark"])
    .default("transparent")
    .describe("Background"),
})

export const SectionBlock = defineBlock({
  type: "section",
  label: "Section",
  category: "layout",
  icon: "Square",
  defaultProps: {
    maxWidth: "default",
    padding: "medium",
    background: "transparent",
  },
  propsSchema: sectionSchema,
  component: SectionComponent,
  slots: [{ name: "content", label: "Section Content" }],
})
```

```tsx
function SectionComponent({
  maxWidth = "default",
  padding = "medium",
  background = "transparent",
  slotContents,
}: SectionProps & { slotContents?: Record<string, React.ReactNode> }) {
  const maxWidthClasses = {
    narrow: "max-w-2xl",
    default: "max-w-5xl",
    wide: "max-w-7xl",
    full: "max-w-none",
  } as const

  const paddingClasses = {
    none: "",
    small: "px-4 py-6",
    medium: "px-6 py-12",
    large: "px-8 py-20",
  } as const

  const bgClasses = {
    transparent: "",
    muted: "bg-muted",
    accent: "bg-accent/5",
    dark: "bg-foreground text-background",
  } as const

  return (
    <section className={`${paddingClasses[padding]} ${bgClasses[background]}`}>
      <div className={`mx-auto ${maxWidthClasses[maxWidth]}`}>
        {slotContents?.content}
      </div>
    </section>
  )
}
```

### Nested Slots Pattern

Blocks with slots can be nested. A Columns block can contain Section blocks, which can contain Rich Text blocks. The nesting depth is unlimited — the rendering system handles it recursively.

```
Page
└── Columns Block (3 columns)
    ├── column-0: Section Block
    │   └── content: Rich Text Block
    ├── column-1: Image Block
    └── column-2: Section Block
        └── content: CTA Block
```

### Restricting Nesting

Use `allowedTypes` to prevent infinite or nonsensical nesting:

```ts
// A Section block that cannot contain other Section blocks
slots: [
  {
    name: "content",
    label: "Content",
    allowedTypes: ["rich-text", "image", "cta", "features-grid", "stats"],
    // Omit "section" to prevent Section-in-Section nesting
  },
],
```

---

## 9. Performance Considerations

### Keep Components Pure

Blocks are re-rendered frequently during editing (on every prop change, selection change, and viewport switch). Avoid expensive computations in the render path:

```tsx
// Avoid: recomputing on every render
function BadExample({ items }: Props) {
  const sorted = items.sort((a, b) => a.order - b.order) // Mutates array!
  return <div>{sorted.map(...)}</div>
}

// Prefer: stable computation
function GoodExample({ items }: Props) {
  const sorted = [...items].sort((a, b) => a.order - b.order)
  return <div>{sorted.map(...)}</div>
}
```

### Avoid Side Effects

Block components should not:

- Fetch data (data comes from the Delivery API via the component tree)
- Write to localStorage or sessionStorage
- Subscribe to global events
- Use `useEffect` for anything beyond basic DOM measurement

### Image Optimization

For image props, use the tenant app's image optimization (e.g., Next.js `<Image>` or responsive `srcset`):

```tsx
function ImageBlockComponent({ src, alt, width, height }: ImageBlockProps) {
  return (
    <img
      src={src}
      alt={alt ?? ""}
      width={width}
      height={height}
      loading="lazy"
      decoding="async"
      className="h-auto w-full"
    />
  )
}
```

---

## 10. Full Example: Tabs Block

A complete example combining dynamic slots, conditional rendering, and viewport-responsive props:

```tsx
"use client"

import { defineBlock } from "@glokki-cms/sdk"
import { useState } from "react"
import { z } from "zod"

const tabItemSchema = z.object({
  label: z.string().min(1).max(60).describe("Tab Label"),
  icon: z
    .enum(["none", "star", "heart", "info", "settings"])
    .default("none")
    .describe("Icon"),
})

const tabsSchema = z.object({
  tabs: z
    .array(tabItemSchema)
    .default([
      { label: "Overview", icon: "none" },
      { label: "Features", icon: "star" },
      { label: "Pricing", icon: "none" },
    ])
    .describe("Tabs"),
  alignment: z
    .enum(["left", "center", "stretch"])
    .default("left")
    .describe("Tab Alignment"),
  showIcons: z.boolean().default(false).describe("Show Tab Icons"),
})

type TabsProps = z.infer<typeof tabsSchema> & {
  slotContents?: Record<string, React.ReactNode>
}

const ICON_MAP: Record<string, string> = {
  star: "\u2605",
  heart: "\u2764\uFE0F",
  info: "\u2139\uFE0F",
  settings: "\u2699\uFE0F",
}

function TabsComponent({
  tabs = [],
  alignment = "left",
  showIcons = false,
  slotContents,
}: TabsProps) {
  const [activeIndex, setActiveIndex] = useState(0)

  const alignmentClasses = {
    left: "justify-start",
    center: "justify-center",
    stretch: "",
  } as const

  return (
    <div className="px-6 py-8">
      <div
        className={`flex border-b ${alignmentClasses[alignment]}`}
        role="tablist"
      >
        {tabs.map((tab, i) => (
          <button
            key={`${tab.label}-${i}`}
            role="tab"
            aria-selected={i === activeIndex}
            onClick={() => setActiveIndex(i)}
            className={`px-4 py-2 text-sm font-medium transition-colors ${
              alignment === "stretch" ? "flex-1" : ""
            } ${
              i === activeIndex
                ? "border-b-2 border-primary text-primary"
                : "text-muted-foreground hover:text-foreground"
            }`}
          >
            {showIcons && tab.icon !== "none" && (
              <span className="mr-1.5">{ICON_MAP[tab.icon]}</span>
            )}
            {tab.label}
          </button>
        ))}
      </div>
      <div className="pt-6" role="tabpanel">
        {slotContents?.[`tab-${activeIndex}`]}
      </div>
    </div>
  )
}

export const TabsBlock = defineBlock({
  type: "tabs",
  label: "Tabs",
  category: "layout",
  icon: "Layers",
  defaultProps: {
    tabs: [
      { label: "Overview", icon: "none" as const },
      { label: "Features", icon: "star" as const },
      { label: "Pricing", icon: "none" as const },
    ],
    alignment: "left",
    showIcons: false,
  },
  propsSchema: tabsSchema,
  component: TabsComponent,
  slots: (props) =>
    props.tabs.map((tab: { label: string }, i: number) => ({
      name: `tab-${i}`,
      label: tab.label,
    })),
  viewportDefaults: {
    mobile: {
      alignment: "stretch",
    },
  },
})
```

This example demonstrates:

- **`"use client"` directive** — required because the component uses `useState`
- **Dynamic slots** derived from the `tabs` array prop
- **Conditional rendering** of icons via `showIcons` toggle
- **Viewport-responsive defaults** that stretch tabs on mobile
- **Client-side state** (`useState`) for active tab tracking
- **Slot contents** rendered in the active tab panel

> **Note on index-based slot names:** This example uses `tab-${i}` for slot names, which means slot identity is positional. If editors reorder tabs, child blocks stay attached to slot indexes, not tab labels. For blocks where tab reordering is common, consider adding a stable ID field to the tab item schema and using it as the slot name instead.

- **Accessibility** with `role="tablist"`, `role="tab"`, and `aria-selected`

---

## 11. Checklist for Advanced Blocks

Beyond the [basic checklist](/docs/block-development#10-checklist), verify:

- [ ] **Slots render correctly** — child blocks appear in the right slot areas
- [ ] **Empty slots have placeholders** — editors can see where to drop blocks
- [ ] **Dynamic slots update** — changing the controlling prop creates/removes slot zones
- [ ] **`allowedTypes` is set** — prevent nonsensical block nesting
- [ ] **Viewport defaults are sensible** — mobile view simplifies rather than just shrinks
- [ ] **Conditional sections have toggle props** — editors can hide/show optional elements
- [ ] **Conditional guards check both toggle and value** — e.g., `showCta && ctaLabel`
- [ ] **No hardcoded colors** — use semantic tokens or color picker fields
- [ ] **Component is pure** — no data fetching, no side effects, no DOM manipulation
- [ ] **Component handles missing `slotContents`** — renders gracefully without children

---

## Next Steps

- [Block Development Guide](/docs/block-development) — fundamentals of block creation
- [Integration Guide](/docs/integration) — project setup and visual editor integration
- [Troubleshooting Guide](/docs/troubleshooting) — common issues and solutions
- [API Reference](/docs/api-reference) — full SDK export documentation

---

Source: https://glokki.com/docs/data-model-write-api

# Data Model Write API

Create and update data model records — the reference data blocks and collection fields point at (authors, venues, service hours) — from a script or an MCP agent, without a browser session.

This guide covers the record endpoints under `/api/v1/admin/data-models/{modelKey}/records`, plus the six MCP tools that wrap the same operations. It does not cover creating or editing a data model's _definition_ (its `fieldsSchema`, `displayField`, etc.) — that stays dashboard-only, see [What is deliberately absent](#what-is-deliberately-absent). For the general MCP connection flow (endpoint URL, `claude mcp add`, rate limits), see the [MCP Server Guide](/docs/mcp-server).

---

## Table of Contents

1. [What it is](#what-it-is)
2. [Getting a key](#getting-a-key)
3. [Auth](#auth)
4. [Discovering the schema](#discovering-the-schema)
5. [Writing](#writing)
6. [Error handling](#error-handling)
7. [What is deliberately absent](#what-is-deliberately-absent)
8. [MCP](#mcp)

---

## What it is

A **data model** is a tenant-defined record type (e.g. "Authors", "Venues", "Service hours") with a field schema, shown in the dashboard under **Settings → Data Models**. A **record** is one instance of it (e.g. one author). Blocks and collection fields with a `dataModelRef` field reference a record by its **id** (a UUID) — the record's optional `key` is a separate, stable identifier meant for exactly this API: it lets an external script address (and idempotently upsert) a record by something more legible and re-derivable than a database id.

This API lets a trusted server-side caller — a sync script holding an `ek_live_` API key, or an MCP agent — create and update records without going through the dashboard UI. Two callers, one set of rules:

- **REST**, authenticated with a bearer API key or a dashboard session: `POST`, `PATCH`, and `PUT …/by-key/{key}` on `/api/v1/admin/data-models/{modelKey}/records`.
- **MCP**, authenticated the same way (the key just arrives as the MCP bearer token): six tools, listed in [MCP](#mcp).

Both callers go through the same service functions, the same field-schema validation, and the same post-write side effects (cache purge, activity log).

---

## Getting a key

1. Go to **Settings → API keys** in the dashboard.
2. Create a new key and tick both `admin:data-models:read` and `admin:data-models:write`.
3. Copy the plain-text key (`ek_live_…`) — it is shown once, at creation time.

**An existing key cannot be given a new scope from the UI.** The API keys table only _displays_ a key's permissions as read-only badges; there is no edit-scopes dialog. If a key you already issued doesn't have `admin:data-models:read`/`:write`, the only way to add them is to **issue a new key** with the scopes ticked up front, and swap it into whatever script or agent config used the old one.

---

## Auth

Every request needs:

```
Authorization: Bearer ek_live_…
```

`X-Tenant-Slug` does nothing on these routes — **don't send it as a cross-tenant guard.** The tenant is always derived from the key itself (a key belongs to exactly one tenant), so there's no header needed for routing to work. If you do send one, it never reaches the code that would check it: the proxy strips `X-Tenant-Slug` from every request under `/api/v1/admin/` before your request is handled (it exists there to stop a client from spoofing another tenant on the _session_ path), so a mismatched value is silently dropped, not validated. There is no way to make this header fail a request on these routes.

If a script is parameterized across multiple tenants and you want a mismatch to fail loudly, pair `slug → key` in the script's own configuration instead — that's a map you already control — and, if you want the call itself to prove the pairing is right, read back `GET /api/v1/admin/data-models` first and confirm the model keys you expect are present before writing.

A dashboard session cookie works the same way as a bearer key on every endpoint this guide covers except two: `DELETE` on a record, and any write to a model's own definition, refuse a bearer key outright (`403 API_KEY_NOT_ACCEPTED`) — see [What is deliberately absent](#what-is-deliberately-absent).

---

## Discovering the schema

Before writing a record, find the model's `fieldsSchema` — record `data` is validated against it, so guessing field names fails the write.

### List the tenant's data models

```bash
curl "$CMS_BASE_URL/api/v1/admin/data-models" \
  -H "Authorization: Bearer $CMS_API_KEY"
```

```json
{
  "data": [
    {
      "id": "8f14e45f-ceea-4b7d-9503-92a1f2b1c5aa",
      "tenantId": "11111111-1111-4111-8111-111111111111",
      "key": "authors",
      "name": "Authors",
      "description": "Blog post authors",
      "icon": "user",
      "displayField": "name",
      "fieldsSchema": [
        {
          "name": "name",
          "label": "Name",
          "fieldType": "text",
          "required": true
        },
        {
          "name": "bio",
          "label": "Bio",
          "fieldType": "textarea",
          "required": false
        },
        {
          "name": "photo",
          "label": "Photo",
          "fieldType": "image",
          "required": false
        }
      ],
      "createdBy": "77777777-7777-4777-8777-777777777777",
      "createdAt": "2026-09-20T10:00:00.000Z",
      "updatedAt": "2026-09-20T10:00:00.000Z"
    }
  ],
  "meta": { "total": 1 }
}
```

The list already carries each model's full `fieldsSchema` (it's the same row shape as the single-model lookup below), so for a tenant with only a handful of models this one call may be all you need. `GET /{modelKey}` is worth a separate call once you know which model you're targeting — one row, not the whole list — and is what you'll use every time a script re-validates its assumptions before writing.

### Get one model's schema

```bash
curl "$CMS_BASE_URL/api/v1/admin/data-models/authors" \
  -H "Authorization: Bearer $CMS_API_KEY"
```

```json
{
  "data": {
    "id": "8f14e45f-ceea-4b7d-9503-92a1f2b1c5aa",
    "tenantId": "11111111-1111-4111-8111-111111111111",
    "key": "authors",
    "name": "Authors",
    "description": "Blog post authors",
    "icon": "user",
    "displayField": "name",
    "fieldsSchema": [
      {
        "name": "name",
        "label": "Name",
        "fieldType": "text",
        "required": true
      },
      {
        "name": "bio",
        "label": "Bio",
        "fieldType": "textarea",
        "required": false
      },
      {
        "name": "photo",
        "label": "Photo",
        "fieldType": "image",
        "required": false
      }
    ],
    "createdBy": "77777777-7777-4777-8777-777777777777",
    "createdAt": "2026-09-20T10:00:00.000Z",
    "updatedAt": "2026-09-20T10:00:00.000Z"
  }
}
```

Both endpoints accept a key with `admin:data-models:read` (or a dashboard session).

### List a model's records

```bash
curl "$CMS_BASE_URL/api/v1/admin/data-models/authors/records?limit=25" \
  -H "Authorization: Bearer $CMS_API_KEY"
```

```json
{
  "data": [
    {
      "id": "fb48c64f-1234-4abc-9def-0123456789ab",
      "tenantId": "11111111-1111-4111-8111-111111111111",
      "dataModelId": "8f14e45f-ceea-4b7d-9503-92a1f2b1c5aa",
      "key": "jane-doe",
      "data": { "name": "Jane Doe", "bio": "Senior staff writer." },
      "createdBy": "77777777-7777-4777-8777-777777777777",
      "createdAt": "2026-09-27T12:00:00.000Z",
      "updatedAt": "2026-09-27T12:00:00.000Z"
    }
  ],
  "meta": { "total": 1 }
}
```

Query params: `search` (matches the model's `displayField` value or the record's `key`), `sort` (`updatedAt` or `displayField`, default `updatedAt`), `direction` (`asc`/`desc`, default `desc`), `limit` (1–100, default 25), and `offset`.

### Get one record

```bash
curl "$CMS_BASE_URL/api/v1/admin/data-models/authors/records/fb48c64f-1234-4abc-9def-0123456789ab" \
  -H "Authorization: Bearer $CMS_API_KEY"
```

`200 OK` with the same record shape as the list above. `404 DATA_MODEL_RECORD_NOT_FOUND` if `{recordId}` doesn't exist, or belongs to a different model than `{modelKey}` names.

Both record GETs accept a key with `admin:data-models:read` (or a dashboard session), same as the two model GETs above.

---

## Writing

Three endpoints, all requiring `admin:data-models:write` (or a dashboard session):

| Method  | Path                                                        | Use it to                                                              |
| ------- | ----------------------------------------------------------- | ---------------------------------------------------------------------- |
| `POST`  | `/api/v1/admin/data-models/{modelKey}/records`              | Create a new record, with an optional caller-chosen `key`.             |
| `PATCH` | `/api/v1/admin/data-models/{modelKey}/records/{recordId}`   | Update an existing record by its id.                                   |
| `PUT`   | `/api/v1/admin/data-models/{modelKey}/records/by-key/{key}` | Create-or-replace a record by its stable `key` — an idempotent upsert. |

`data` is a free-form JSON object validated structurally against the model's `fieldsSchema` (required fields present, values of a plausible shape for their `fieldType`). A record's optional `key` — when set, on create or via `PATCH` — must match `^[a-z][a-z0-9-]{0,63}$` (lowercase letters, digits, and hyphens, starting with a letter, up to 64 characters) and is unique within the model.

### Create — `POST …/records`

```bash
curl -X POST "$CMS_BASE_URL/api/v1/admin/data-models/authors/records" \
  -H "Authorization: Bearer $CMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"key": "jane-doe", "data": {"name": "Jane Doe", "bio": "Staff writer."}}'
```

`201 Created`:

```json
{
  "data": {
    "id": "fb48c64f-1234-4abc-9def-0123456789ab",
    "tenantId": "11111111-1111-4111-8111-111111111111",
    "dataModelId": "8f14e45f-ceea-4b7d-9503-92a1f2b1c5aa",
    "key": "jane-doe",
    "data": { "name": "Jane Doe", "bio": "Staff writer." },
    "createdBy": "77777777-7777-4777-8777-777777777777",
    "createdAt": "2026-09-27T12:00:00.000Z",
    "updatedAt": "2026-09-27T12:00:00.000Z"
  }
}
```

`key` is optional — omit it for a record that's only ever addressed by its `id`.

### Update by id — `PATCH …/records/{recordId}`

```bash
curl -X PATCH "$CMS_BASE_URL/api/v1/admin/data-models/authors/records/fb48c64f-1234-4abc-9def-0123456789ab" \
  -H "Authorization: Bearer $CMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data": {"name": "Jane Doe", "bio": "Senior staff writer."}}'
```

`200 OK` with the updated record, same shape as the `POST` response above. Pass `key`, `data`, or both — either can be omitted, but when `data` **is** sent, it _replaces the whole object_; there is no partial/merge update of individual fields inside `data`.

### Upsert by key — `PUT …/records/by-key/{key}`

This is the endpoint to reach for from an idempotent sync script — one that re-imports the same external dataset on a schedule and doesn't want to track which records it already created.

```bash
curl -X PUT "$CMS_BASE_URL/api/v1/admin/data-models/authors/records/by-key/jane-doe" \
  -H "Authorization: Bearer $CMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data": {"name": "Jane Doe", "bio": "Senior staff writer."}}'
```

- **`201 Created`** — no record with this `key` existed for this model; one was created.
- **`200 OK`** — a record with this `key` already existed; its `data` was **replaced wholly** with the payload you sent (not merged), and its `createdBy` is left untouched — the original author stays attributed even when someone else's key runs the sync.
- **Re-running the identical request is safe.** The write is a single `INSERT … ON CONFLICT DO UPDATE`, so two calls back-to-back — or two concurrent calls — converge on one row, never a duplicate.

The URL's `{key}` segment is validated against the same `^[a-z][a-z0-9-]{0,63}$` pattern before any database work; a malformed key is refused with `400 INVALID_RECORD_KEY`, not a `404` (a malformed key can never have matched an existing record, so answering "not found" would be misleading).

---

## Error handling

All errors return JSON shaped `{ "error": { "code", "message", "details"? } }`; the codes below are what these endpoints can return. `VALIDATION_ERROR` and `DATA_MODEL_RECORD_VALIDATION_FAILED` both carry per-field messages in `details`, but nested differently — see the two example bodies below.

| HTTP | `code`                                | Cause                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| ---- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 400  | `VALIDATION_ERROR`                    | Request body fails the endpoint's own Zod schema — missing `data`, or an invalid `key` format on `POST`/`PATCH`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| 400  | `INVALID_RECORD_KEY`                  | The `by-key` URL segment doesn't match `^[a-z][a-z0-9-]{0,63}$`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| 400  | `DATA_MODEL_RECORD_VALIDATION_FAILED` | `data` fails structural validation against the model's `fieldsSchema` — check `details.fieldErrors`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| 401  | `UNAUTHORIZED`                        | The `Authorization` header is missing or malformed, or the key is unknown, revoked, or expired. (A key whose creating user account no longer exists is refused too, but with `401 USER_NOT_FOUND` instead — same status, different code.)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| 403  | `INSUFFICIENT_SCOPE`                  | The key is valid but lacks the scope the endpoint requires (`admin:data-models:read` or `:write`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| 403  | `API_KEY_NOT_ACCEPTED`                | The endpoint is session-only. This covers record `DELETE` and any write to a model's own definition — a bearer key gets this instead of a login redirect.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| 404  | `DATA_MODEL_NOT_FOUND`                | `{modelKey}` doesn't name a data model in this tenant.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| 404  | `DATA_MODEL_RECORD_NOT_FOUND`         | `{recordId}` — on `GET` or `PATCH` — doesn't exist (or belongs to a different model).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| 409  | `DATA_MODEL_RECORD_KEY_TAKEN`         | `POST` with a `key` already used by another record in this model. (`PUT …/by-key/{key}` can't hit this — it upserts on the same key instead of colliding with it.)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| 429  | `RATE_LIMITED`                        | A rate-limit budget is exhausted. Reads and writes are metered separately, and each budget has two ceilings: a tenant-wide one, shared across every bearer-authenticated call in that category this tenant makes (not just data model records), and a per-key one at half the tenant figure, rounded up, so one key alone can never spend the whole tenant budget.<br><br>Writes (`POST`, `PATCH`, `PUT`) are metered on `admin:write` — **100 requests / 60s tenant-wide, 50 requests / 60s per key**.<br>Reads (all four `GET`s) are metered on `admin:read` — **200 requests / 60s tenant-wide, 100 requests / 60s per key**.<br><br>Whichever ceiling is hit first refuses the call, and the message names the figure it enforced, e.g. `Write limit reached (50/min)`. That message says "Write" even when it was a read budget that refused you — the label is not per category, so read the number, not the word. |
| 503  | `RATE_LIMIT_UNAVAILABLE`              | The rate limiter itself is unreachable (e.g. Redis is down). Writes fail **closed** here rather than let an unmetered burst through. Back off and retry on your own schedule — there is no `Retry-After` header on this response, and the numeric retry hint in `details` is only present outside production, so don't rely on reading one back from a deployed instance.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |

Two example bodies, since the two "invalid data" codes nest their field errors differently:

```json
// VALIDATION_ERROR — details IS the fieldErrors map. The message text below
// is Zod's own generated wording (e.g. "Invalid input: expected record,
// received undefined") and can shift between Zod versions — the shape
// (an array of strings per field) is what's load-bearing, not this exact string.
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": {
      "data": ["Invalid input: expected record, received undefined"]
    }
  }
}
```

```json
// DATA_MODEL_RECORD_VALIDATION_FAILED — details wraps it under "fieldErrors"
{
  "error": {
    "code": "DATA_MODEL_RECORD_VALIDATION_FAILED",
    "message": "Record data does not match the data model's fields schema",
    "details": { "fieldErrors": { "name": ["Name is required"] } }
  }
}
```

---

## What is deliberately absent

- **No delete.** `DELETE …/records/{recordId}` exists, but only for a dashboard session — a bearer key gets `403 API_KEY_NOT_ACCEPTED`. There is no way to delete a record over this API.
- **No bulk payloads.** Every write endpoint takes exactly one record per request. Importing many records means one request per record (the upsert-by-key endpoint is what makes that safe to re-run).
- **No writing model _definitions_.** The `admin:data-models:*` scopes only ever gate the endpoints in this guide — the four record/model **read** GETs and the three record **writes**. Creating a data model, changing its `fieldsSchema`, renaming it, or deleting it goes through separate `POST`/`PATCH`/`DELETE` endpoints under `/api/v1/admin/data-models` (and `/{modelKey}`) that are session-only, same as record delete, regardless of what scopes a bearer key carries. This guide only covers records.

---

## MCP

The same read/write operations are available as six MCP tools, gated by the same two scopes (plus `mcp:connect`, required to open any MCP connection — see the [MCP Server Guide](/docs/mcp-server)):

| Tool                       | Scope                     |
| -------------------------- | ------------------------- |
| `list_data_models`         | `admin:data-models:read`  |
| `get_data_model`           | `admin:data-models:read`  |
| `list_data_model_records`  | `admin:data-models:read`  |
| `get_data_model_record`    | `admin:data-models:read`  |
| `create_data_model_record` | `admin:data-models:write` |
| `update_data_model_record` | `admin:data-models:write` |

**Call `get_data_model` before either write tool.** Both `create_data_model_record` and `update_data_model_record` validate `data` against the model's field schema, exactly like the REST endpoints do — guessing field names fails the write the same way it would over HTTP.

**`update_data_model_record` needs `expectedUpdatedAt`.** `get_data_model_record` (and every record row from `list_data_model_records`) returns an `updatedAt` token; pass it back as `expectedUpdatedAt` on the update call. If the record changed since you read it — someone else's edit landed in between — the call is refused with `409 STALE_RECORD` instead of silently overwriting that change; re-read with `get_data_model_record` and retry with the fresh token. Every successful write also returns the record's new `updatedAt`, so a chain of edits against the same record doesn't need a re-read between each one.

There is no MCP upsert-by-key tool and no MCP delete tool — those two gaps mirror [What is deliberately absent](#what-is-deliberately-absent) above.

---

Source: https://glokki.com/docs/editor-preview-route

# Editor Preview Route

How to mount the createEditorPreviewPage() helper so the CMS can edit block templates in the real visual editor — without needing a real page.

Block templates are saved `ComponentNode[]` trees that you can use as starting layouts for new pages, as insertable snippets from the Component Library, or both. They exist only inside the CMS — no slug, no URL — so the editor iframe has no page to load. The **editor preview route** is a contentless route in your tenant app that subscribes to the CMS editor bridge and renders whatever tree the CMS sends it. Mount it once, and every template you ever create is editable in the same iframe editor you already use for pages.

This is a **one-time setup per tenant app**. Once the route is live, there is **zero per-template configuration** — new templates are immediately editable with no additional wiring.

---

## Table of Contents

1. [Prerequisites](#1-prerequisites)
2. [Mount the Helper](#2-mount-the-helper)
3. [How the Route Works](#3-how-the-route-works)
4. [CSP Headers](#4-csp-headers)
5. [Custom Chrome (Theme & Fonts)](#5-custom-chrome-theme--fonts)
6. [Data Models](#6-data-models)
7. [Troubleshooting](#7-troubleshooting)

---

## 1. Prerequisites

- You have already followed the [Integration Guide](/docs/integration) and have a working block registry, visual editor, and CSP middleware.
- `@glokki-cms/react` is at a version that exports `createEditorPreviewPage`. Confirm with `npm ls @glokki-cms/react`.
- Your block registry is defined once and imported from a single module (e.g. `blocks/registry.ts`). The preview route must import the **same** registry used by your main page route so previews render with the same components.
- You have a layout registry built with `createLayoutRegistry()` (e.g. `layouts/registry.ts`), as described in the [Layout Development Guide](/docs/layout-development). The helper requires it: the handshake ships it to the CMS, and the preview uses it to render your header/footer chrome around the block tree when the CMS asks for a layout frame.

---

## 2. Mount the Helper

`createEditorPreviewPage()` returns a client component that mounts the passive visual editor host. Because its return value holds React components from your block registry, it must be instantiated inside a client module. The recommended layout is a **two-file pattern**: one client module that calls the factory, and a thin server `page.tsx` that renders it and owns the route's metadata.

> **Next.js App Router trap.** Folders whose name begins with a literal underscore (`app/_foo/`) are [private folders](https://nextjs.org/docs/app/getting-started/project-structure#private-folders) and are **not routable**. To keep a leading underscore in the URL, use the URL-encoded form `%5F` in the folder name (`app/%5Feditor-preview/`), which routes to `/_editor-preview` as expected. The code samples below use this form.

### Choosing the path

By default the CMS opens the preview route at `/_editor-preview`. If you mount the helper somewhere else, tell the CMS: open **Settings → Visual Editor** in the CMS Dashboard and set **Editor Preview Path** to the route's URL path (leave it blank to use the default). The path must:

- start with `/` and have no trailing slash (a bare `/` is accepted);
- contain only letters, numbers, `-`, `_`, `.` and `/`, with at least one letter or number in every segment (so `/_` or `/---` are rejected);
- be a static route — no query string, hash, whitespace, or dynamic `[param]` segments;
- be at most 200 characters.

Whatever path you choose, the Next.js folder must still map to it — a leading underscore in any segment needs the `%5F` folder form described above.

### Client module

Create the client component that wires in your block and layout registries:

```tsx
// app/%5Feditor-preview/preview-client.tsx
"use client"

import { createEditorPreviewPage } from "@glokki-cms/react"

import { blockRegistry } from "@/blocks/registry"
import { layoutRegistry } from "@/layouts/registry"

export default createEditorPreviewPage({
  blockRegistry,
  layoutRegistry,
  tenantSlug: process.env.NEXT_PUBLIC_CMS_TENANT,
})
```

`blockRegistry` and `layoutRegistry` are required. `tenantSlug` is optional but recommended — see [Tenant guard](#tenant-guard) below.

### Tenant guard

Pass `tenantSlug` — your tenant's slug, the same value as `CMS_TENANT` — to turn on a cross-tenant misconfiguration guard. Because the preview client is a client module, read it from a `NEXT_PUBLIC_`-prefixed variable (`NEXT_PUBLIC_CMS_TENANT=acme`); a plain `CMS_TENANT` is not exposed to the browser.

With the guard on, the preview declares its slug in the handshake and checks the slug of the editing session the CMS replies with. If they differ — typically because one tenant's **Frontend URL** points at another tenant's app — the connection is refused: the CMS shows a tenant-mismatch connection error naming both slugs. The preview independently checks the session slug too — on a mismatch it logs `Editor session refused` to the browser console and never signals ready, and the CMS only sends draft content after ready, so no foreign content is exchanged. If you omit `tenantSlug`, no check is made. The same option exists on `<VisualEditorProvider>` for your regular page editor shell — see the [Integration Guide](/docs/integration#visualeditorprovider-props).

### Server page wrapper

Create the route's `page.tsx` as a server component. This is where you declare metadata to keep the route out of search engines:

```tsx
// app/%5Feditor-preview/page.tsx
import type { Metadata } from "next"

import PreviewClient from "./preview-client"

export const metadata: Metadata = {
  robots: { index: false, follow: false },
}

export default function EditorPreviewPage() {
  return <PreviewClient />
}
```

That's it. The route is now live at `/_editor-preview` (or the path you configured) and ready to host any block template the CMS sends.

---

## 3. How the Route Works

The preview route is intentionally passive: it renders **nothing** to normal visitors and crawlers, and only activates when the CMS opens it inside the editor iframe.

When the CMS opens a template, it loads `https://your-site.com/_editor-preview?_editor=1&_editorOrigin=<cms-origin>&_editorToken=<token>` (with your configured **Editor Preview Path** in place of `/_editor-preview`) in the iframe. The helper reads `_editor` and `_editorOrigin` from the URL — if either is missing, the component renders `null` and the route **cannot be indexed or abused as an open renderer**. Inside editor mode, it mounts `<VisualEditorProvider>` + `<CMSPageEditor>` wired to your block registry, sends `window.location.pathname` as `pageSlug` in the handshake, and then renders whatever `ComponentNode[]` the CMS streams over `tree:update` — exactly as a real page would.

---

## 4. CSP Headers

If you followed the [Integration Guide's CSP middleware](/docs/integration#8-configure-csp-headers), the preview route already works: the middleware checks for `?_editor=1` on **every** request, not just real pages, so `frame-ancestors` is set correctly here too. No changes needed.

Double-check that the `matcher` on your request middleware (`middleware.ts` on Next.js 14–15, `proxy.ts` on Next.js 16+) does not exclude `/_editor-preview`. If it does, you'll see the CMS iframe fail with an `X-Frame-Options` or `frame-ancestors` violation in the browser console.

> **Warning — editor rewrites.** A common pattern for keeping public pages static (ISR) is to have the request middleware **rewrite** every `?_editor=1` request to a separate, dynamic editor route. The CMS opens the preview route with `?_editor=1` too, so such a rewrite would swallow it and the preview helper would never mount. Exclude the editor preview path from the rewrite — but keep applying the `frame-ancestors` headers to it:

```ts
// proxy.ts (middleware.ts on Next.js 14–15)
import { EDITOR_QUERY_PARAM } from "@glokki-cms/sdk"
import { type NextRequest, NextResponse } from "next/server"

const cmsOrigin = new URL(process.env.CMS_BASE_URL ?? "http://localhost:3000")
  .origin
const EDITOR_PREVIEW_PATH = "/_editor-preview" // must match the CMS setting

export default function proxy(request: NextRequest) {
  const { pathname, searchParams } = request.nextUrl
  const isEditorMode = searchParams.get(EDITOR_QUERY_PARAM) === "1"

  let response: NextResponse
  if (isEditorMode && pathname !== EDITOR_PREVIEW_PATH) {
    // Page editing: send the request to your dynamic editor route
    const url = request.nextUrl.clone()
    url.pathname = `/editor${pathname}`
    response = NextResponse.rewrite(url)
  } else {
    // Public pages AND the editor preview route pass through untouched
    response = NextResponse.next()
  }

  if (isEditorMode) {
    response.headers.set(
      "Content-Security-Policy",
      `frame-ancestors 'self' ${cmsOrigin}`
    )
    response.headers.delete("X-Frame-Options")
  } else {
    response.headers.set("X-Frame-Options", "SAMEORIGIN")
  }

  return response
}
```

---

## 5. Custom Chrome (Theme & Fonts)

By default the helper wraps the rendered tree in a bare `<main>`. Real blocks usually need the tenant app's theme context — CSS variables, font classes, providers — to look right. Pass a `chrome` component to wrap the editor with whatever context your blocks need. Adding it to the client module from section 2:

```tsx
// app/%5Feditor-preview/preview-client.tsx
"use client"

import type { ReactNode } from "react"

import { createEditorPreviewPage } from "@glokki-cms/react"

import { blockRegistry } from "@/blocks/registry"
import { ThemeProvider } from "@/components/theme-provider"
import { layoutRegistry } from "@/layouts/registry"
import { inter } from "@/lib/fonts"

function EditorChrome({ children }: { children: ReactNode }) {
  return (
    <ThemeProvider>
      <main className={inter.className}>{children}</main>
    </ThemeProvider>
  )
}

export default createEditorPreviewPage({
  blockRegistry,
  layoutRegistry,
  chrome: EditorChrome,
})
```

The `chrome` prop receives the rendered block tree as `children`. Use it for anything the blocks themselves depend on but don't own: font classes, CSS variable scopes, theme providers, container widths, etc. Avoid putting anything with navigation (headers/footers) inside the chrome — templates are meant to be edited in isolation.

---

## 6. Data Models

If your tenant app uses data model references (via `createDataModelRegistry()`), forward that registry to the helper as well. Without it, blocks that reference data models will render with empty data inside the editor even though they render correctly in production.

Extend the factory call from section 2 with the additional registry:

```tsx
export default createEditorPreviewPage({
  blockRegistry,
  layoutRegistry,
  dataModelRegistry,
})
```

The registry is forwarded through the editor handshake so the CMS can resolve `dataModelRef` fields against the tenant's declared set while editing a template.

---

## 7. Troubleshooting

**The iframe is blank and nothing renders.**
Check the URL bar of the embedded iframe. If `?_editor=1&_editorOrigin=...` is missing, the CMS isn't opening the route in editor mode — verify the Frontend URL and the Editor Preview Path in the CMS Dashboard's visual editor settings. If the query params are present but you still see a blank page, open devtools and confirm the handshake message appears in the console; a missing `blockRegistry` export is the most common cause.

**Blocks render but look unstyled.**
Your blocks need the tenant app's theme context. Pass a `chrome` component that wraps children in your theme provider and font classes — see [Custom Chrome](#5-custom-chrome-theme--fonts).

**The CMS reports a tenant mismatch, or the console shows `Editor session refused`.**
The `tenantSlug` you passed to the helper doesn't match the tenant whose editor opened the iframe. Either `NEXT_PUBLIC_CMS_TENANT` is wrong for this deployment, or the CMS tenant's **Frontend URL** points at another tenant's app. See [Tenant guard](#tenant-guard).

**Visiting `/_editor-preview` directly in a browser shows an empty page.**
This is intentional. The route refuses to render outside editor mode so it can't be indexed or abused. Open a template in the CMS to see the route activate.

**Crawlers are indexing `/_editor-preview`.**
They shouldn't be — the server `page.tsx` exports `metadata.robots: { index: false, follow: false }`, and the route returns an empty document outside editor mode. To keep crawlers away entirely, disallow the path in `robots.txt`. The [Sitemap & robots.txt](/docs/sitemap-and-robots) guide has an `app/robots.ts` that does this (use the path from your Editor Preview Path setting if you changed it). Don't add a `public/robots.txt` as well: Next.js reports a conflict when both exist.

---

## Related

- [Integration Guide](/docs/integration) — the baseline setup this route builds on.
- [Layout Development Guide](/docs/layout-development) — building the `layoutRegistry` the helper requires.
- [Block Development](/docs/block-development) — defining blocks that the preview route will render.
- [Sitemap & robots.txt](/docs/sitemap-and-robots) — keeping the preview route out of search engines.

---

Source: https://glokki.com/docs/integration

# Integration Guide

How to integrate glokki into your application using @glokki-cms/sdk and @glokki-cms/react.

This guide walks you through installing the SDK, defining blocks, rendering published content, and enabling visual editing. Code examples use Next.js (the recommended framework), with notes for plain React where the approach differs.

---

## Table of Contents

1. [Prerequisites](#1-prerequisites)
2. [Install Packages](#2-install-packages)
3. [Create the CMS Client](#3-create-the-cms-client)
4. [Define Blocks](#4-define-blocks)
5. [Create the Block Registry](#5-create-the-block-registry)
6. [Render Published Pages](#6-render-published-pages)
7. [Enable Visual Editing](#7-enable-visual-editing)
8. [Configure CSP Headers](#8-configure-csp-headers)
9. [Set `frontendUrl` in CMS Settings](#9-set-frontendurl-in-cms-settings)
10. [Preview Mode](#10-preview-mode)
11. [On-Demand Revalidation](#11-on-demand-revalidation)
12. [SEO Metadata](#12-seo-metadata)
13. [Plain React (Non-Next.js)](#13-plain-react-non-nextjs)

---

## 1. Prerequisites

- **Node.js 20+** or **Bun 1.0+**
- A running Editor CMS instance (default: `http://localhost:3000`)
- A tenant with at least one published page
- A Delivery API key (CMS Dashboard → Settings → API Keys)

---

## 2. Install Packages

```bash
npm install @glokki-cms/sdk @glokki-cms/react zod
```

Or with Bun:

```bash
bun add @glokki-cms/sdk @glokki-cms/react zod
```

The SDK provides the delivery API client, block definition utilities, and the visual editor protocol. The React package provides components for rendering pages and enabling visual editing.

### Next.js Configuration

Add both packages to `transpilePackages` in `next.config.mjs`:

```js
/** @type {import('next').NextConfig} */
const nextConfig = {
  transpilePackages: ["@glokki-cms/react", "@glokki-cms/sdk"],
}

export default nextConfig
```

### Environment Variables

Create a `.env` file:

```bash
# Required
CMS_BASE_URL=http://localhost:3000    # URL where the CMS is running
CMS_TENANT=acme                       # Your tenant slug

# Required in production
APP_ENV=production                    # Anything else (or unset) = noindex, nofollow

# Recommended
CMS_API_KEY=ek_live_...               # Delivery API key (Settings → API Keys)
NEXT_PUBLIC_SITE_URL=https://acme.com # Public URL of your site (for SEO)
NEXT_PUBLIC_CMS_TENANT=acme           # Same as CMS_TENANT, readable in client code (editor tenant guard)

# Optional
CMS_REVALIDATE_SECRET=your-secret     # HMAC secret for on-demand revalidation
```

> **Set `APP_ENV=production` on your production deployment.** `cmsMetadata()` forces `robots: noindex, nofollow` on every page whenever `APP_ENV` is anything other than `production` — **including when it is unset**. This keeps staging and preview deploys out of search indexes, but a production site without it will be de-indexed. Leave it unset (or set e.g. `APP_ENV=staging`) everywhere else. See [SEO Metadata](#12-seo-metadata).

---

## 3. Create the CMS Client

The SDK provides `createClient()` to connect to the Delivery API.

```ts
// lib/cms.ts
import { createClient } from "@glokki-cms/sdk"
import { cache } from "react"

export const cmsConfig = {
  baseUrl: process.env.CMS_BASE_URL ?? "http://localhost:3000",
  apiKey: process.env.CMS_API_KEY,
  tenantSlug: process.env.CMS_TENANT ?? "acme",
} as const

export const cms = createClient({
  ...cmsConfig,
  fetchInit: { next: { tags: ["cms-pages"] } }, // Enables on-demand revalidation
})

// Deduplicate identical calls within a single server render pass
export const getPageBySlug = cache((slug: string) => cms.pages.getBySlug(slug))
```

### Client Options

| Option         | Type           | Description                                        |
| -------------- | -------------- | -------------------------------------------------- |
| `baseUrl`      | `string`       | CMS instance URL                                   |
| `apiKey`       | `string?`      | Delivery API key                                   |
| `tenantSlug`   | `string?`      | Tenant identifier (sent as `X-Tenant-Slug` header) |
| `preview`      | `boolean?`     | Enable preview mode (fetches draft content)        |
| `previewToken` | `string?`      | Authentication token for preview requests          |
| `fetchInit`    | `RequestInit?` | Default fetch options (cache tags, headers, etc.)  |

### Available API Methods

```ts
// Pages
const page = await cms.pages.getBySlug("/about")
const { data, meta } = await cms.pages.list({ locale: "en", limit: 10 })

// Navigation
const nav = await cms.navigation.get({ locale: "en" })

// Redirects
const redirects = await cms.redirects.list()

// Collections
const { data } = await cms.collections.list()
const collection = await cms.collections.getBySlug("blog")
const { entries, pagination } = await cms.collections.listEntries("blog", {
  limit: 20,
  offset: 0,
})

// A/B Testing
const variant = await cms.experiments.resolveVariant(experimentId, visitorId)
await cms.experiments.recordConversion(experimentId, visitorId)
```

---

## 4. Define Blocks

Blocks are the building units of CMS pages. Each block has a **Zod schema** (defining its editable props) and a **React component** (rendering the block).

Use `defineBlock()` from the SDK:

```tsx
// blocks/hero.tsx
import { defineBlock } from "@glokki-cms/sdk"
import { z } from "zod"

const heroSchema = z.object({
  heading: z
    .string()
    .min(1)
    .max(120)
    .default("Welcome to our site")
    .describe("Heading"),
  subheading: z.string().max(240).optional().describe("Subheading"),
  backgroundImage: z
    .string()
    .url()
    .optional()
    .meta({ fieldType: "image" })
    .describe("Background Image"),
  ctaLabel: z.string().max(60).optional().describe("CTA Label"),
  ctaUrl: z.string().url().default("#").describe("CTA URL"),
  alignment: z
    .enum(["left", "center", "right"])
    .default("center")
    .describe("Alignment"),
})

type HeroProps = z.infer<typeof heroSchema>

function HeroComponent({
  heading = "Welcome to our site",
  subheading,
  backgroundImage,
  ctaLabel,
  ctaUrl = "#",
  alignment = "center",
}: HeroProps) {
  return (
    <section className="flex min-h-[400px] flex-col justify-center px-8 py-16">
      <h1 className="text-4xl font-bold">{heading}</h1>
      {subheading && <p className="mt-4 text-lg opacity-80">{subheading}</p>}
      {ctaLabel && (
        <a
          href={ctaUrl}
          className="mt-6 inline-flex rounded-md bg-accent px-6 py-2.5 font-medium"
        >
          {ctaLabel}
        </a>
      )}
    </section>
  )
}

export const HeroBlock = defineBlock({
  type: "hero",
  label: "Hero",
  category: "content",
  icon: "Sparkles",
  description: "Full-width banner with heading and CTA",
  defaultProps: {
    heading: "Welcome to our site",
    ctaUrl: "#",
    alignment: "center",
  },
  propsSchema: heroSchema,
  component: HeroComponent,
})
```

### Schema-to-Editor Field Mapping

The CMS automatically generates an editing form from your Zod schema. Here's how Zod types map to editor fields:

| Zod Type                                     | Editor Field     | Notes                        |
| -------------------------------------------- | ---------------- | ---------------------------- |
| `z.string()`                                 | Text input       | Default for strings          |
| `z.string().meta({ fieldType: "textarea" })` | Textarea         | Multi-line text              |
| `z.string().meta({ fieldType: "richtext" })` | Rich text editor | HTML content                 |
| `z.string().meta({ fieldType: "image" })`    | Image picker     | URL with upload UI           |
| `z.string().url()`                           | URL input        | With URL validation          |
| `z.number()`                                 | Number input     | Respects `.min()` / `.max()` |
| `z.boolean()`                                | Toggle switch    | On/off                       |
| `z.enum(["a", "b", "c"])`                    | Select dropdown  | Options from enum values     |
| `z.string().meta({ fieldType: "color" })`    | Color picker     | Hex color value              |
| `z.array(z.object({...}))`                   | Repeatable list  | Nested item forms            |

Additional schema features:

- **`.describe("Label")`** — sets the field label in the props panel
- **`.meta({ placeholder: "..." })`** — sets placeholder text
- **`.meta({ description: "..." })`** — adds help text below the field
- **`.meta({ hidden: true })`** — hides the field from the editor
- **`.default(value)`** — sets the default value; makes the field optional
- **`.min()` / `.max()`** — adds validation constraints

### `defineBlock()` Options

| Option             | Type                                                | Required | Description                                                                                                                                                                                                                                         |
| ------------------ | --------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`             | `string`                                            | Yes      | Unique block identifier (e.g., `"hero"`)                                                                                                                                                                                                            |
| `label`            | `string`                                            | Yes      | Display name in the CMS block picker                                                                                                                                                                                                                |
| `category`         | `BlockCategory` (a `string`)                        | Yes      | Category key this block is grouped under — free-form, 1–64 characters, no whitespace. It must appear in the registry's `categories` option when one is declared; otherwise use a CMS built-in (`content`, `layout`, `media`, `interactive`, `list`) |
| `icon`             | `string`                                            | Yes      | Lucide icon name (e.g., `"Sparkles"`)                                                                                                                                                                                                               |
| `description`      | `string`                                            | No       | Short description shown in block picker                                                                                                                                                                                                             |
| `defaultProps`     | `TProps`                                            | Yes      | Default values (must satisfy the schema)                                                                                                                                                                                                            |
| `propsSchema`      | `ZodObject`                                         | Yes      | Zod schema defining editable props                                                                                                                                                                                                                  |
| `component`        | `ComponentType`                                     | Yes      | React component that renders the block                                                                                                                                                                                                              |
| `viewportDefaults` | `object`                                            | No       | Prop overrides for `tablet` and `mobile` viewports                                                                                                                                                                                                  |
| `slots`            | `SlotDefinition[]` or `(props) => SlotDefinition[]` | No       | Named content slots for nesting blocks                                                                                                                                                                                                              |

---

## 5. Create the Block Registry

Collect all `defineBlock()` results into a registry:

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

import { CtaBlock } from "./cta"
import { HeroBlock } from "./hero"
import { RichTextBlock } from "./rich-text"

export const blockRegistry = createBlockRegistry([
  HeroBlock,
  CtaBlock,
  RichTextBlock,
])

// Derive a component Map for the renderer
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 used during visual editing — it sends full block definitions (schemas, metadata, defaults) to the CMS via the v2 handshake protocol. The `registry` Map is used by the page renderer to resolve block types to components.

To add a new block, create it with `defineBlock()` and add it to the `createBlockRegistry()` array. No CMS-side configuration is needed — the CMS discovers blocks automatically via the handshake.

### Naming your own categories

By default the block picker groups blocks under the CMS's built-in categories
(`content`, `layout`, `media`, `interactive`, `list`). Pass an ordered
`categories` list as `createBlockRegistry()`'s second argument to use your own
instead:

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

The list **replaces** the built-ins rather than merging with them, and the
picker shows the categories 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 list (or
`undefined` when you declared none), and it travels to the CMS on the same
handshake as the block definitions, so it needs `@glokki-cms/react` 0.9.0 or
later.

---

## 6. Render Published Pages

Create a catch-all route that fetches pages from the Delivery API:

```tsx
// app/[[...slug]]/page.tsx
import { CMSPage } from "@glokki-cms/react"
import { EDITOR_QUERY_PARAM } from "@glokki-cms/sdk"
import { notFound } from "next/navigation"

import { EditorShell } from "@/blocks/editor-shell"
import { registry } from "@/blocks/registry"
import { getPageBySlug } from "@/lib/cms"

type PageProps = {
  params: Promise<{ slug?: string[] }>
  searchParams: Promise<Record<string, string | string[] | undefined>>
}

export default async function CmsPage({ params, searchParams }: PageProps) {
  const [{ slug }, search] = await Promise.all([params, searchParams])
  const pageSlug = slug ? `/${slug.join("/")}` : "/"
  const isEditorMode = search[EDITOR_QUERY_PARAM] === "1"

  // Editor mode: render the visual editor shell (see Step 7)
  if (isEditorMode) {
    return <EditorShell pageSlug={pageSlug} />
  }

  const page = await getPageBySlug(pageSlug)
  if (!page) notFound()

  return <CMSPage componentTree={page.componentTree} registry={registry} />
}
```

`<CMSPage>` renders the component tree by resolving each block's `type` against the `registry` Map and passing the block's `props` to the matched component. Unrecognized types render an "Unknown Block" placeholder.

---

## 7. Enable Visual Editing

When the CMS opens your page in its iframe editor, it appends `?_editor=1` to the URL. Your app needs to detect this and render the visual editor shell instead of the static page.

Create a client component that wraps `<VisualEditorProvider>` and `<CMSPageEditor>`:

```tsx
// blocks/editor-shell.tsx
"use client"

import { CMSPageEditor, VisualEditorProvider } from "@glokki-cms/react"
import type { ComponentNode } from "@glokki-cms/sdk"

import { layoutRegistry } from "@/layouts/registry"
import { blockRegistry, registry } from "./registry"

export function EditorShell({
  pageSlug,
  fallbackTree,
}: {
  pageSlug: string
  fallbackTree?: ComponentNode[]
}) {
  return (
    <VisualEditorProvider
      registry={registry}
      blockRegistry={blockRegistry}
      layoutRegistry={layoutRegistry}
      fallbackTree={fallbackTree}
      pageSlug={pageSlug}
      tenantSlug={process.env.NEXT_PUBLIC_CMS_TENANT}
    >
      <CMSPageEditor />
    </VisualEditorProvider>
  )
}
```

### How It Works

1. **`<VisualEditorProvider>`** reads `?_editor=1`, `?_editorOrigin`, and `?_editorToken` from the URL
2. It initiates a **postMessage handshake** with the CMS, sending the full block registry (schemas, metadata, defaults)
3. The CMS responds with the current component tree and editor state
4. **`<CMSPageEditor>`** renders the live page with editor overlays — block selection, hover highlights, drag handles, and gap inserters
5. As the user edits in the CMS props panel, tree updates stream into the iframe in real time

### `<VisualEditorProvider>` Props

| Prop             | Type                         | Required | Description                                                                                                                                                                |
| ---------------- | ---------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `registry`       | `Map<string, ComponentType>` | Yes      | Component lookup map                                                                                                                                                       |
| `blockRegistry`  | `BlockRegistry`              | No       | Enables v2 handshake with full block definitions                                                                                                                           |
| `layoutRegistry` | `LayoutRegistry`             | Yes      | Layout registry from `createLayoutRegistry()` — sent in the handshake and used to render header/footer chrome. See the [Layout Development Guide](/docs/layout-development) |
| `fallbackTree`   | `ComponentNode[]`            | No       | Tree to render before the CMS sends the first update                                                                                                                       |
| `pageSlug`       | `string`                     | No       | Page slug sent to the CMS during handshake                                                                                                                                 |
| `tenantSlug`     | `string`                     | No       | Your tenant slug (e.g. `process.env.NEXT_PUBLIC_CMS_TENANT`). Enables the cross-tenant guard — see below                                                                   |

**Tenant guard.** When `tenantSlug` is set, the provider declares it in the handshake and compares it with the slug of the editing session the CMS replies with. On a mismatch — usually a **Frontend URL** pointing at another tenant's app — the CMS shows a tenant-mismatch connection error, and the provider logs `Editor session refused` to the browser console and never signals ready, so no draft content reaches your app. This is a client component, so the slug must come from a `NEXT_PUBLIC_` variable; a plain `CMS_TENANT` is `undefined` in the browser. Omit the prop to skip the check.

### Editing Block Templates

The same visual editor can also edit **block templates** — saved `ComponentNode[]` trees with no real page behind them. That requires mounting a dedicated passive preview route in your tenant app via `createEditorPreviewPage()`. It's a one-time setup with zero per-template work thereafter. See the [Editor Preview Route guide](/docs/editor-preview-route) for the full walkthrough.

### `useVisualEditor()` Hook

Access editor state from any component inside the provider:

```tsx
import { useVisualEditor } from "@glokki-cms/react"

function MyComponent() {
  const {
    componentTree, // Current block tree
    selectedBlockId, // Currently selected block (or null)
    viewport, // "desktop" | "tablet" | "mobile"
    isEditorMode, // true when inside the CMS iframe
    isTreeSynced, // true after first tree:update from CMS
    editorOrigin, // CMS origin (null when not in editor mode)
  } = useVisualEditor()
}
```

---

## 8. Configure CSP Headers

The CMS embeds your app in an iframe. By default, browsers block iframe embedding via `X-Frame-Options`. You need to configure a `Content-Security-Policy: frame-ancestors` header that allows the CMS origin — but **only in editor mode**.

### Next.js Middleware

Create a middleware file at your project root:

```ts
// middleware.ts
import { EDITOR_QUERY_PARAM } from "@glokki-cms/sdk"
import { type NextRequest, NextResponse } from "next/server"

const cmsOrigin = (() => {
  const base = process.env.CMS_BASE_URL || "http://localhost:3000"
  try {
    return new URL(base).origin
  } catch {
    return base
  }
})()

export default function middleware(request: NextRequest) {
  const isEditorMode =
    request.nextUrl.searchParams.get(EDITOR_QUERY_PARAM) === "1"
  const response = NextResponse.next()

  if (isEditorMode) {
    // Allow the CMS to embed this page in an iframe
    response.headers.set(
      "Content-Security-Policy",
      `frame-ancestors 'self' ${cmsOrigin}`
    )
    // Remove X-Frame-Options as it takes precedence in older browsers
    response.headers.delete("X-Frame-Options")
  } else {
    // Prevent clickjacking on public pages
    response.headers.set("X-Frame-Options", "SAMEORIGIN")
  }

  return response
}
```

This ensures:

- **Editor mode** (`?_editor=1`): the CMS origin can embed your page in an iframe
- **Normal mode**: `X-Frame-Options: SAMEORIGIN` prevents third-party iframe embedding

If your middleware also **rewrites** `?_editor=1` requests to a separate dynamic route (to keep public pages static), exclude the editor preview path from that rewrite — see [Editor Preview Route → CSP Headers](/docs/editor-preview-route#4-csp-headers).

---

## 9. Set `frontendUrl` in CMS Settings

The CMS needs to know where your tenant app is hosted so it can load it in the visual editor iframe.

1. Open the CMS Dashboard
2. Go to **Settings → Visual Editor**
3. Set **Frontend URL** to your app's URL (e.g., `http://localhost:4000` for local dev, or `https://acme.com` for production)
4. Save

The CMS uses this URL to construct the iframe `src` when opening the page editor. It appends query parameters for editor mode, authentication, and origin targeting:

```
https://acme.com/about?_editor=1&_editorToken=<token>&_editorOrigin=https://cms.editor.app
```

---

## 10. Preview Mode

Preview mode lets content editors view draft (unpublished) pages. The CMS generates a preview link with a token that authenticates the request.

```ts
// lib/preview.ts
import { createClient } from "@glokki-cms/sdk"
import { cmsConfig } from "./cms"

type PreviewParams =
  | { isPreview: true; token: string }
  | { isPreview: false; token: undefined }

export function parsePreviewParams(
  searchParams: Record<string, string | string[] | undefined>
): PreviewParams {
  const preview = searchParams.preview
  const token =
    typeof searchParams.token === "string" ? searchParams.token : undefined
  if (preview === "true" && token) {
    return { isPreview: true, token }
  }
  return { isPreview: false, token: undefined }
}

export function createPreviewClient(token: string) {
  return createClient({
    ...cmsConfig,
    preview: true,
    previewToken: token,
    fetchInit: { cache: "no-store" }, // Never cache preview content
  })
}
```

Add preview support to your page route:

```tsx
// In app/[[...slug]]/page.tsx
import { PreviewBanner } from "@glokki-cms/react"
import { createPreviewClient, parsePreviewParams } from "@/lib/preview"

// Inside your page component:
const { isPreview, token } = parsePreviewParams(search)

const page = isPreview
  ? await createPreviewClient(token).pages.getBySlug(pageSlug)
  : await getPageBySlug(pageSlug)

if (!page) notFound()

// Render with preview banner:
return (
  <>
    {isPreview && <PreviewBanner />}
    <CMSPage componentTree={page.componentTree} registry={registry} />
  </>
)
```

The `<PreviewBanner>` renders a fixed amber banner at the top of the page indicating the content is a draft.

---

## 11. On-Demand Revalidation

When content is published in the CMS, you want your cached pages to update immediately. The CMS can send a signed webhook to your app to trigger revalidation.

```ts
// app/api/revalidate/route.ts
import crypto from "node:crypto"
import { revalidatePath, revalidateTag } from "next/cache"

export async function POST(request: Request) {
  const secret = process.env.CMS_REVALIDATE_SECRET
  if (!secret) {
    return Response.json(
      { error: "CMS_REVALIDATE_SECRET not configured" },
      { status: 500 }
    )
  }

  // Verify HMAC-SHA256 signature
  const timestamp = request.headers.get("X-Editor-Timestamp")
  const signature = request.headers.get("X-Editor-Signature")
  if (!timestamp || !signature) {
    return Response.json(
      { error: "Missing signature headers" },
      { status: 401 }
    )
  }

  const body = await request.text()
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${body}`)
    .digest("hex")

  const sigBuf = Buffer.from(signature)
  const expectedBuf = Buffer.from(expected)
  if (
    sigBuf.length !== expectedBuf.length ||
    !crypto.timingSafeEqual(sigBuf, expectedBuf)
  ) {
    return Response.json({ error: "Invalid signature" }, { status: 401 })
  }

  const payload = JSON.parse(body) as { slug?: string }
  const path = payload.slug ? `/${payload.slug}` : "/"

  revalidateTag("cms-pages")
  revalidatePath(path)

  return Response.json({ revalidated: true, path })
}
```

### Other payloads on the same URL

The Revalidation URL is the CMS's single tenant-facing webhook endpoint. Besides the slug payload above, it also receives:

| Payload            | Shape                                    | Sent when                                                                                                                                      |
| ------------------ | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Cache invalidation | `{ type: "invalidate", tags: string[] }` | a page is published, unpublished, or archived (consumed by `@glokki-cms/cache` — see [Tenant-side Postgres cache](/docs/tenant-cache-postgres)) |
| Data-model purge   | `{ surrogateKey, modelKey, recordId }`   | a data-model record is created, updated, or deleted                                                                                            |

All of them carry the same `X-Editor-Signature` / `X-Editor-Timestamp` headers and are signed with the same secret.

**Always answer `200` for payloads you don't handle.** The CMS dispatcher treats any non-2xx response as a failed delivery and retries it up to 5 times with exponential backoff, so a handler that returns `400` on a missing `slug` produces retry noise on every publish without gaining anything. The handler above already behaves correctly — a payload without `slug` falls through to `revalidateTag("cms-pages")` and returns `200`. Keep that property if you customize it.

### CMS Configuration

1. Go to **Settings → Visual Editor** in the CMS Dashboard
2. Set **Revalidation URL** to `https://your-site.com/api/revalidate`
3. Set a **Revalidation Secret** — copy this value to your app's `CMS_REVALIDATE_SECRET` env var

---

## 12. SEO Metadata

The React package provides `cmsMetadata()` to generate Next.js `Metadata` from CMS page data:

```tsx
// app/[[...slug]]/page.tsx
import { cmsMetadata } from "@glokki-cms/react"
import type { Metadata } from "next"

export async function generateMetadata({
  params,
  searchParams,
}: PageProps): Promise<Metadata> {
  const [{ slug }, search] = await Promise.all([params, searchParams])
  const { isPreview } = parsePreviewParams(search)

  // Prevent preview pages from being indexed
  if (isPreview) {
    return { robots: { index: false, follow: false } }
  }

  const pageSlug = slug ? `/${slug.join("/")}` : "/"
  const page = await getPageBySlug(pageSlug)
  return cmsMetadata(page, { baseUrl: process.env.NEXT_PUBLIC_SITE_URL })
}
```

This generates:

- `<title>` and `<meta name="description">` from the page's SEO fields
- `<link rel="canonical">` with the base URL
- Hreflang `<link rel="alternate">` tags for multi-locale pages
- Open Graph and Twitter Card meta tags
- `robots` directives

> **Warning — `APP_ENV` controls indexing.** Unless `APP_ENV` is exactly `production`, `cmsMetadata()` returns `robots: { index: false, follow: false }` for **every** page, overriding the robots setting editors chose in the CMS. An unset `APP_ENV` counts as non-production. Set `APP_ENV=production` on your production deployment, or your live site will be de-indexed. To decide per call instead, pass `env` explicitly: `cmsMetadata(page, { baseUrl, env: "production" })`.

For JSON-LD structured data, use the `<JsonLd>` component:

```tsx
import { JsonLd } from "@glokki-cms/react"

// Inside your page component:
;<JsonLd data={page.seo.jsonLd} />
```

`page.seo.jsonLd` is an optional array of JSON-LD objects; `<JsonLd>` renders one `<script type="application/ld+json">` per item and renders nothing when the field is missing or empty, so no guard is needed. Editors fill it in the page editor's SEO panel under **Structured Data**, which offers templates for Article, Product, FAQ, Breadcrumb, and Organisation, plus a custom JSON-LD option.

For `sitemap.xml` and `robots.txt`, see the [Sitemap & robots.txt guide](/docs/sitemap-and-robots).

---

## 13. Plain React (Non-Next.js)

If you're not using Next.js, the core integration is the same with a few differences:

### Client Setup

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

const cms = createClient({
  baseUrl: "https://cms.editor.app",
  apiKey: "ek_live_...",
  tenantSlug: "acme",
})

// Fetch pages in your data layer (e.g., React Router loader, useEffect, etc.)
const page = await cms.pages.getBySlug("/about")
```

### Page Rendering

```tsx
import { CMSPage } from "@glokki-cms/react"
import { registry } from "./blocks/registry"

function Page({ page }) {
  return <CMSPage componentTree={page.componentTree} registry={registry} />
}
```

### Visual Editor

The visual editor requires the same `<VisualEditorProvider>` + `<CMSPageEditor>` setup. Detect editor mode by checking for `?_editor=1` in the URL:

```tsx
import { CMSPageEditor, VisualEditorProvider } from "@glokki-cms/react"
import { EDITOR_QUERY_PARAM } from "@glokki-cms/sdk"
import { blockRegistry, registry } from "./blocks/registry"
import { layoutRegistry } from "./layouts/registry"

function Page({ page, slug }) {
  const params = new URLSearchParams(window.location.search)
  const isEditorMode = params.get(EDITOR_QUERY_PARAM) === "1"

  if (isEditorMode) {
    return (
      <VisualEditorProvider
        registry={registry}
        blockRegistry={blockRegistry}
        layoutRegistry={layoutRegistry}
        pageSlug={slug}
      >
        <CMSPageEditor />
      </VisualEditorProvider>
    )
  }

  return <CMSPage componentTree={page.componentTree} registry={registry} />
}
```

### CSP Headers

Configure your server or CDN to set `Content-Security-Policy: frame-ancestors 'self' https://cms.editor.app` when the `_editor=1` query parameter is present. The exact mechanism depends on your hosting platform (e.g., Cloudflare Workers, Express middleware, Vercel edge config).

### Caching & Revalidation

Without Next.js ISR, implement your own caching strategy:

- Cache API responses with a TTL
- Expose a webhook endpoint to bust the cache when content is published
- Use the same HMAC signature verification as shown in [On-Demand Revalidation](#11-on-demand-revalidation)

### SEO

`cmsMetadata()` returns a plain object compatible with Next.js `Metadata`. In other frameworks, map the fields to your framework's head management (e.g., `react-helmet`, `@tanstack/react-router` meta):

```ts
import { cmsMetadata } from "@glokki-cms/react"

const metadata = cmsMetadata(page, { baseUrl: "https://acme.com" })
// metadata.title, metadata.description, metadata.openGraph, etc.
```

---

Source: https://glokki.com/docs/layout-development

# 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

---

Source: https://glokki.com/docs/markdown-for-agents

# Markdown for agents

Serve every published page as clean Markdown so LLM agents and crawlers get the content without the page chrome.

Serve every published page as clean Markdown so LLM agents and crawlers get the
content without the chrome: `https://your-site.com/about.md`, or `/about` with
`Accept: text/markdown`.

Everything runs in **your** app. The CMS is not involved: blocks decide how
they render to Markdown in `defineBlock()`, and the SDK walks the page tree,
resolves bound fields and serves the response.

## Checklist

| Step                                                       | Required | Section                                                                  |
| ---------------------------------------------------------- | -------- | ------------------------------------------------------------------------ |
| Upgrade to `@glokki-cms/sdk` 0.16.0 or later               | yes      | [Prerequisites](#prerequisites)                                          |
| Add `app/md/[[...slug]]/route.ts` and the `.md` rewrite    | yes      | [1. Mount the route](#1-mount-the-route)                                 |
| Pass `resolve` and `pageUrl` if your URLs carry a locale   | if i18n  | [Sites with locale prefixes](#sites-with-locale-prefixes)                |
| Serve Markdown on the page URL for `Accept: text/markdown` | optional | [Content negotiation](#content-negotiation)                              |
| Add `markdown` to the blocks the fallback renders poorly   | optional | [2. Teach blocks to render Markdown](#2-teach-blocks-to-render-markdown) |
| Check the output with `curl`                               | yes      | [Verify it](#verify-it)                                                  |

Nothing changes in the CMS: no setting, no deploy, no API key scope.

## Prerequisites

- `@glokki-cms/sdk` **0.16.0** or later. If you also use `@glokki-cms/react`
  or `@glokki-cms/cache`, upgrade them together (0.9.1 and 0.1.2 or later):
  they declare `^0.16.0` as their SDK range, and a `0.x` caret only matches
  the same minor version.
- A server runtime for the route. It fetches the page at request time, so it
  does not work with `output: "export"`.

## 1. Mount the route

```ts
// app/md/[[...slug]]/route.ts
import { createMarkdownRoute } from "@glokki-cms/sdk"

import { blockRegistry } from "@/blocks/registry"
import { cms } from "@/lib/cms"

export const { GET } = createMarkdownRoute({
  client: cms,
  blockRegistry,
  siteUrl: "https://your-site.com",
})
```

```ts
// next.config.ts
export default {
  async rewrites() {
    return [{ source: "/:path*.md", destination: "/md/:path*" }]
  },
}
```

The home page is `/index.md` (or `/md`).

The route handler imports your block registry, and with it your block
components. That is fine, because they are never rendered there. The registry
module must not run browser-only code (`window`, `document`) at import time,
though.

### Options

| Option             | Default                                              | Use it when                                                                                            |
| ------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `resolve(request)` | strips `/md` and `.md` from the path                 | your URLs carry a locale (`/pl/o-nas.md` → `{ slug: "o-nas", locale: "pl" }`); return `null` for a 404 |
| `pageUrl(page)`    | `siteUrl + "/" + slug`                               | the canonical HTML URL differs from the slug (locale prefixes)                                         |
| `cacheControl`     | `public, s-maxage=300, stale-while-revalidate=86400` | you want a different CDN policy                                                                        |

Because the route reuses your `client`, its cache tags apply, so the
revalidation webhook refreshes the `.md` and the HTML together.

Pass a **published-content** client. A client created with `preview: true` and
a `previewToken` would serve draft pages, and the route caches its responses
publicly.

### Sites with locale prefixes

The default `resolve` turns the path into a slug and nothing else, so on a site
that prefixes non-default locales (`/pl/o-nas`), `/pl/o-nas.md` would look up
the slug `pl/o-nas` in the default locale and 404. Map the request through the
same function your HTML catch-all route uses, and build the canonical URL with
the prefix:

```ts
// lib/markdown.ts
import type { DeliveryPage } from "@glokki-cms/sdk"

import { defaultLocale, resolveRoute } from "./i18n"

export function resolveMarkdownRequest(request: Request) {
  const { pathname } = new URL(request.url)
  let path = pathname
  try {
    path = decodeURIComponent(pathname)
  } catch {
    // Malformed escape: keep the raw path; the route 404s on unsafe slugs.
  }
  const segments = path
    .replace(/^\/md(?=\/|$)/, "")
    .replace(/\.md$/, "")
    .split("/")
    .filter(Boolean)
  if (segments.at(-1) === "index") segments.pop() // /pl/index.md → pl home
  return resolveRoute(segments) // { locale, slug }; return null instead to 404
}

export function markdownPageUrl(siteUrl: string, page: DeliveryPage) {
  const base = siteUrl.replace(/\/+$/, "")
  const prefix = page.locale === defaultLocale ? "" : `/${page.locale}`
  const slug = page.slug.replace(/^\/+/, "")
  if (!slug) return prefix ? `${base}${prefix}` : `${base}/`
  return `${base}${prefix}/${slug}`
}
```

```ts
// app/md/[[...slug]]/route.ts
export const { GET } = createMarkdownRoute({
  client: cms,
  blockRegistry,
  siteUrl,
  resolve: resolveMarkdownRequest,
  pageUrl: (page) => markdownPageUrl(siteUrl, page),
})
```

The route still rejects unsafe slugs (`..`, `?`, `#`, `%`, `\`) after your
`resolve` runs, so a custom resolver doesn't need its own path checks.

### Content negotiation

Optional: serve Markdown on the page's own URL when an agent asks for it with
`Accept: text/markdown`. Merge this into your existing `proxy.ts`, **after**
redirect handling, so a moved page still redirects first:

```ts
// proxy.ts (Next 16)
import { type NextRequest, NextResponse } from "next/server"

/** Framework files, API/CDN routes, the Markdown route itself and files. */
const NON_PAGE_PATH_RE = /^\/(?:_next|api|cdn|md)(?:\/|$)|\.[^/]+$/

export function proxy(request: NextRequest) {
  // ...your redirect handling first

  const { pathname } = request.nextUrl
  if (
    !NON_PAGE_PATH_RE.test(pathname) &&
    request.headers.get("accept")?.includes("text/markdown")
  ) {
    const url = request.nextUrl.clone()
    url.pathname = `/md${pathname === "/" ? "" : pathname}`
    return NextResponse.rewrite(url)
  }

  return NextResponse.next()
}

// If you'd rather not filter in code, scope the proxy with a matcher instead:
// export const config = {
//   matcher: ["/((?!_next/|api/|cdn/|md(?:/|$)|.*\\.[^/]+$).*)"],
// }
```

Check your CDN before you turn this on. The page URL now serves two bodies,
and the Markdown one is cacheable (`public, s-maxage=300`). Its
`Vary: Accept` header is what stops a shared cache from handing it to a
browser. You can't add `Accept` to the HTML response's `Vary` yourself: Next
sets that header on App Router pages and overwrites values from `proxy.ts` or
`next.config` `headers()`. So a cache that honours `Vary` is safe, and at worst
gives an agent the cached HTML. A CDN that ignores `Vary` (Cloudflare does,
outside images) could serve the stored Markdown to browsers. Behind such a CDN,
skip negotiation and keep to the `.md` URLs, which never share a cache key with
HTML.

### What the route returns

- **200**: `text/markdown` with YAML frontmatter (`title`, `description`, `url`,
  `locale`, `updated`) and the page body. Headers: `X-Robots-Tag: noindex`
  (keeps search engines from indexing a duplicate of your HTML),
  `Link: <url>; rel="canonical"` and `Vary: Accept`.
- **404**: the page doesn't exist, or its SEO robots setting contains `noindex`.
- **503**: the CMS couldn't be reached. Sent with `Cache-Control: no-store` so
  a CDN never caches the failure.

## 2. Teach blocks to render Markdown

Add a `markdown` function to `defineBlock()`. It receives the block's props
(with bound fields such as `pageField("title")` already resolved) and a
`MarkdownContext`:

```ts
export const HeroBlock = defineBlock({
  type: "hero",
  // ...label, category, icon, propsSchema, defaultProps, component
  markdown: (props, { md }) =>
    [
      md.heading(1, props.heading),
      props.subheading && md.escape(props.subheading),
    ]
      .filter(Boolean)
      .join("\n\n"),
})

export const FaqBlock = defineBlock({
  type: "faq",
  // ...
  markdown: (props, { md }) =>
    props.items
      .map(
        (item) =>
          `${md.heading(3, item.question)}\n\n${md.richtext(item.answer)}`
      )
      .join("\n\n"),
})

export const CtaBlock = defineBlock({
  type: "cta",
  // ...
  markdown: (props, { md }) =>
    props.label ? md.link(props.label, props.href) : null,
})

export const CarouselBlock = defineBlock({
  type: "carousel",
  // ...
  markdown: false, // decorative: omit it (and its children)
})
```

`MarkdownContext`:

| Field      | What it holds                                                                                  |
| ---------- | ---------------------------------------------------------------------------------------------- |
| `slots`    | children already rendered to Markdown, keyed by slot name (`"default"` for unslotted children) |
| `children` | all children joined in tree order                                                              |
| `page`     | title, slug, locale, publishedAt, collectionData                                               |
| `md`       | `escape`, `richtext`, `link`, `image`, `list`, `heading` (from `createMarkdownHelpers`)        |

Rules:

- Return `null` or `""` to omit one instance. Return a string otherwise.
- `link`, `image` and `heading` take **plain text** and escape it for you.
  `list` takes items that are already Markdown. Escape any other prop text
  yourself with `md.escape`.
- A block with slots that defines `markdown` must place `ctx.slots` or
  `ctx.children` itself. Children it leaves out are not emitted.
- If the function throws, that block falls back (below) and the error is
  logged. The page still renders.
- `markdown` never leaves your app: it is not sent to the CMS.

## 3. Blocks without `markdown`

They still render. The fallback reads the block's fields in schema order:

| Field type                                              | Output                                                       |
| ------------------------------------------------------- | ------------------------------------------------------------ |
| `text`                                                  | paragraph                                                    |
| `textarea`                                              | paragraph with line breaks kept                              |
| `richtext`                                              | converted to Markdown (headings, lists, links, code, quotes) |
| `array`, `repeater`                                     | bullet list; each row joins its text fields with " — "       |
| everything else (`url`, `image`, `select`, `number`, …) | skipped                                                      |

Children are appended after the block's own text. So a block that keeps
important text in a `select` or `url` field needs a `markdown` function.

## 4. Testing a block's Markdown

```ts
import { createMarkdownHelpers, renderPageToMarkdown } from "@glokki-cms/sdk"

const md = renderPageToMarkdown({
  componentTree: [{ id: "1", type: "hero", props: { heading: "Hi" } }],
  blockRegistry,
  data: { title: "Home", slug: "/", locale: "en", publishedAt: null },
  siteUrl: "https://your-site.com",
})
expect(md).toBe("# Hi\n")
```

`createMarkdownHelpers(siteUrl)` gives you the same `md` object to unit-test a
hook directly.

## Verify it

```bash
curl -i http://localhost:4000/index.md            # home page
curl -i http://localhost:4000/about.md            # a page
curl -i http://localhost:4000/pl/o-nas.md         # a non-default locale
curl -i -H "Accept: text/markdown" http://localhost:4000/about  # negotiation
```

Check that:

- the response is `200` with `Content-Type: text/markdown; charset=utf-8`;
- the frontmatter `url` and the `Link: <...>; rel="canonical"` header point at
  the page's **HTML** URL, with the locale prefix when there is one;
- a page with a richtext block shows its headings, lists and links as Markdown;
- a block you marked `markdown: false` is absent.

## Troubleshooting

| Symptom                                                  | Cause and fix                                                                                                                                                                             |
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/about.md` renders your HTML 404 page                   | The rewrite is missing or not applied. Add `rewrites()` to `next.config` and restart the dev server if it didn't pick up the config change.                                               |
| `/md/about` works but `/about.md` doesn't                | Same as above: the route is mounted, the rewrite isn't.                                                                                                                                   |
| Every page in a non-default locale is `404`              | The default `resolve` doesn't know your locale prefixes. See [Sites with locale prefixes](#sites-with-locale-prefixes).                                                                   |
| One page is `404` although it's published                | Its SEO robots setting contains `noindex`. That is deliberate: a page hidden from search engines is hidden from agents too.                                                               |
| `503 Service unavailable`                                | The CMS was unreachable or rejected the request. Check `CMS_BASE_URL` and `CMS_API_KEY` (a revoked key gives a 503 too); the server log has the error (`[markdown] failed to load page`). |
| `500 Internal error`                                     | Rendering failed outside a block hook (a hook that throws only falls back). The server log has `[markdown] failed to render page`.                                                        |
| A block's important text is missing                      | It lives in a field the fallback skips (`select`, `url`, …). Give the block a `markdown` function.                                                                                        |
| The route crashes at import with `window is not defined` | A block module touches the browser at import time. Move that code into an effect or the component body.                                                                                   |
| Browsers receive Markdown                                | A cache in front of the site ignores `Vary: Accept`. See [Content negotiation](#content-negotiation).                                                                                     |

## Not yet supported

`llms.txt` / `llms-full.txt`, redirects in the Markdown route, preview/draft
pages, layout header/footer, and A/B variants (the control variant is always
served).

---

Source: https://glokki.com/docs/mcp-server

# MCP Server Guide

How AI clients such as Claude Code read and edit pages through the CMS MCP server — API key scopes, connecting a client, and the full tool reference.

The CMS exposes a Model Context Protocol (MCP) server that lets AI clients — such as Claude Code — read and edit pages programmatically through the same permission model as the REST API.

## Prerequisites

1. **Open the visual editor after every deploy of your tenant app** that adds or changes blocks, fields, or layouts. The CMS works from a stored snapshot of your block and layout registries, and that snapshot is refreshed only when someone opens a page in the visual editor — never by a deploy, and never by an MCP call. The `whoami` tool's `blockRegistry` field shows the snapshot's `blockCount` and `schemaSyncedAt`; if it is `null` the registry has never been synced and the block tools refuse with `REGISTRY_NOT_SYNCED`. See [Block registry sync](#block-registry-sync).
2. An API key with the required scopes (see below).

## Creating an API key

Go to **Settings → API Keys** in the dashboard and create a key. Select the scopes that match the operations you need:

| Scope                     | What it grants                                                                                                                                                                                                                                                                               |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mcp:connect`             | **Required** — lets the key authenticate to the MCP endpoint                                                                                                                                                                                                                                 |
| `admin:pages:read`        | `search_pages`, `get_page`, `validate_page`, `list_block_types`, `get_block_schema`, `list_collections`, `suggest_internal_links`, `list_layouts`, `get_layout_schema`, `get_layout`, `list_layout_menus`                                                                                    |
| `admin:pages:write`       | `create_page`, `delete_page`, `update_page`, `add_block`, `update_block_props`, `move_block`, `remove_block`, `update_seo`, `update_collection_data`, `create_collection`, `add_collection_field`, `set_layout_field`, `add_nav_item`, `update_nav_item`, `move_nav_item`, `remove_nav_item` |
| `admin:pages:publish`     | `publish_page`, `unpublish_page`, `publish_layout`                                                                                                                                                                                                                                           |
| `admin:assets:read`       | `search_assets`                                                                                                                                                                                                                                                                              |
| `admin:assets:write`      | `upload_asset_from_url`, `delete_asset`, `update_asset`                                                                                                                                                                                                                                      |
| `admin:data-models:read`  | `list_data_models`, `get_data_model`, `list_data_model_records`, `get_data_model_record`                                                                                                                                                                                                     |
| `admin:data-models:write` | `create_data_model_record`, `update_data_model_record`                                                                                                                                                                                                                                       |

A key that can read, write, and publish pages needs all four scopes: `mcp:connect`, `admin:pages:read`, `admin:pages:write`, `admin:pages:publish`.

**`delete_asset` and `update_asset` need `admin:assets:read` as well as `admin:assets:write`.** Both require an `expectedUpdatedAt` token, and `search_assets` — an `admin:assets:read` tool — is the only MCP read that returns one, so a write-only key can never obtain a token and can never call either tool.

## Endpoint URL

```
https://<your-host>/api/mcp/mcp
```

The path ends in `/mcp` twice — the first segment is the route group, the second is the streamable-HTTP transport discriminator.

## Connecting from Claude Code

```bash
claude mcp add --transport http glokki https://<your-host>/api/mcp/mcp \
  --header "Authorization: Bearer ek_live_…"
```

Replace `<your-host>` with your tenant's hostname (e.g. `cms.example.com`) and `ek_live_…` with the plain-text key shown once at creation time.

After adding, verify the connection:

```
/mcp
```

Claude Code will call `whoami` and display the tenant slug.

## Errors and rate limits

The endpoint answers a refused request with the real reason, not a generic `401`. A missing/bad bearer token, a missing `mcp:connect` scope, an exhausted call budget, and a backend outage are four different failures and get four different responses:

| Status | When                                                                                                       | Body                                                                                                                                   | Notable headers                                                                                          |
| ------ | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `401`  | No `Authorization` header, or the bearer token is invalid, revoked, or expired                             | `{"error":"invalid_token","error_description":"…"}` — OAuth-shaped, wire-compatible with the MCP client's own auth-discovery flow      | `WWW-Authenticate: Bearer error="invalid_token", …`, pointing at `/.well-known/oauth-protected-resource` |
| `403`  | The key is valid but lacks the `mcp:connect` scope needed to open the connection at all                    | `{"error":{"code":"INSUFFICIENT_SCOPE","message":"…","details":{"requiredScope":"mcp:connect"}}}`                                      | —                                                                                                        |
| `429`  | The `mcp:call` budget — tenant-wide or this key's share — is exhausted                                     | `{"error":{"code":"RATE_LIMITED","message":"…","details":{"retryAfter":<seconds>,"scope":"tenant"\|"api_key","category":"mcp:call"}}}` | `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`                         |
| `503`  | Authorizing the request itself failed unexpectedly — a database or Redis outage, or a bug in the auth path | `{"error":{"code":"MCP_UNAVAILABLE" (or a DB error code),"message":"…","details":{"retryAfter":5}}}`                                   | `Retry-After: 5`, `Cache-Control: no-store`                                                              |

A missing scope for an individual _tool_ (e.g. calling `publish_page` without `admin:pages:publish`) never reaches this table — the connection is already open, so the refusal comes back as a normal MCP tool result with `isError: true` and HTTP `200` instead, the same shape as the publish-budget refusal below.

**On `429` and `503`, read `Retry-After` and back off — do not re-handshake.** Both mean "you're fine, slow down" (over budget, or a backend struggling), not "your credentials are bad," so re-authenticating only adds connection load to a system that's already throttled or degraded. If your client treats any non-2xx as a cue to re-handshake and retry, carve `429` and `503` out of that path explicitly.

### Rate limits

| Category      | Tenant limit       | Per-key limit      | Enforced                                                                                                                                             |
| ------------- | ------------------ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mcp:call`    | 300 requests / 60s | 150 requests / 60s | Every request to the endpoint (handshake and tool calls), before any tool runs. Refusal is the transport `429` above.                                |
| `mcp:publish` | 20 calls / 60s     | 10 calls / 60s     | `publish_page`, `unpublish_page`, and `publish_layout` only, after the scope check. Refusal is an MCP tool error, not a transport `429` — see below. |

The per-key figure is half the tenant figure (rounded up) — one key can't consume the whole tenant budget, but many keys together are still capped at the tenant number.

A `publish_page`/`unpublish_page` call is charged against **both** budgets: the HTTP request counts toward `mcp:call`, and the tool counts again toward `mcp:publish`. They're different resources — request volume vs. side-effect volume (CDN purge, SEO audit, webhooks) — so a client can be well within its call budget and still get throttled on publishing.

### The publish refusal looks different

`mcp:publish` is enforced inside the tool handler, not in the preflight, so exhausting it never produces an HTTP `429`. It comes back as an ordinary MCP result:

```json
{
  "isError": true,
  "content": [
    {
      "type": "text",
      "text": "{\"code\":\"RATE_LIMITED\",\"message\":\"Publish limit reached (20/min). Retry after 7s.\",\"details\":{\"retryAfterSeconds\":7,\"category\":\"mcp:publish\"}}"
    }
  ]
}
```

HTTP status is `200` — the request was understood and answered, and the answer is "not now." A client that checks only the HTTP status code will read this as success. Check `isError` on every tool result, and read `details.retryAfterSeconds` (inside the JSON-encoded `content[0].text`) the same way you'd read `Retry-After` on a `429`.

**Two codes arrive this way, not one.** Both carry `details.retryAfterSeconds` and both mean _wait, then retry_:

| `code`                   | When                                                   | What it means                                                                                                                                                                       |
| ------------------------ | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `RATE_LIMITED`           | The `mcp:publish` budget is exhausted                  | You published faster than the budget allows.                                                                                                                                        |
| `RATE_LIMIT_UNAVAILABLE` | The limiter itself is unreachable (e.g. Redis is down) | Nothing is wrong with your call. Publishing fails **closed** while the budget can't be counted — an unmetered publish tool is exactly the write storm the budget exists to prevent. |

`RATE_LIMIT_UNAVAILABLE` is the tool-level equivalent of the transport `503`: transient, retryable, not a reason to re-handshake and not a reason to give up. A client that only recognises `RATE_LIMITED` will treat a limiter outage as a hard failure and abandon work that would have succeeded seconds later.

### Tenant overrides apply to `mcp:publish`, not `mcp:call`

A tenant can override its rate limits from the super-admin settings screen. That override reaches `mcp:publish` — an operator running a bulk import can raise it — but deliberately **not** `mcp:call`. `mcp:publish` is the scarce, side-effect-heavy resource an operator has a real reason to tune per tenant; `mcp:call` is transport-level infrastructure, checked in the preflight before any tenant-specific work happens, on every request including ones that turn out to be refused (bad key, missing scope). Wiring an override into that path would cost a tenant-config lookup on every MCP request — successful or not — to serve a knob nobody needs on the cheap path. This is a decision, not a gap: if a call-budget override doesn't seem to take effect, that's why.

## Available tools

| Tool                       | Required scope            | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| -------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `whoami`                   | `mcp:connect`             | Returns tenant info and the block-registry snapshot's `blockCount` and `schemaSyncedAt` (or `null` if never synced).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `list_block_types`         | `admin:pages:read`        | Lists all registered block types (type, label, category, slots).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `get_block_schema`         | `admin:pages:read`        | Returns field descriptors and default props for one block type. Use before `add_block`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `list_layouts`             | `admin:pages:read`        | Lists the page layouts this tenant's app defines, with the freshness of the server-side layout-schema mirror.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `get_layout_schema`        | `admin:pages:read`        | Returns the field descriptors for one section (header or footer) of one layout.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `get_layout`               | `admin:pages:read`        | Returns the stored draft and published data (each `/cdn/`-enriched) for one section of a layout, and whether the draft has unpublished changes.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `list_layout_menus`        | `admin:pages:read`        | Lists every menu-tree field in one section of a layout as a `fieldPath` — e.g. `drawerSections.0.items` for a menu nested inside a repeater row — addressable by `add_nav_item`, `update_nav_item`, `move_nav_item`, and `remove_nav_item`.                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `set_layout_field`         | `admin:pages:write`       | Sets one field inside a layout section (header or footer) by dotted `fieldPath` — a scalar, a repeater element, or a whole menu-tree array. Requires `expectedUpdatedAt` and a `value` (an omitted `value` is refused, not treated as clearing the field); setting a menu-tree field replaces the whole array while preserving each node's existing `_id`. See [Layout writes and navigation](#layout-writes-and-navigation). Returns the new `draftUpdatedAt`.                                                                                                                                                                                                                                          |
| `add_nav_item`             | `admin:pages:write`       | Inserts a new item into a menu-tree field addressed by `fieldPath`, under `parentId` (or the root) at `index`. Any `_id` on `node` is ignored — a fresh one is always minted. Refuses an unknown `parentId` with `PARENT_NOT_FOUND` and a resulting depth past the field's `maxDepth` with `MAX_DEPTH_EXCEEDED`. See [Layout writes and navigation](#layout-writes-and-navigation). Returns the new node's `itemId` and the new `draftUpdatedAt`.                                                                                                                                                                                                                                                        |
| `update_nav_item`          | `admin:pages:write`       | Merges `patch` into one existing navigation item, addressed by `itemId`. `_id` and `children` in `patch` are ignored. Refuses an unknown `itemId` with `NODE_NOT_FOUND`. See [Layout writes and navigation](#layout-writes-and-navigation). Returns `itemId` and the new `draftUpdatedAt`.                                                                                                                                                                                                                                                                                                                                                                                                               |
| `move_nav_item`            | `admin:pages:write`       | Re-parents/reorders an existing navigation item, addressed by `itemId`, to a new `parentId` (or the root) and `index`. Refuses nesting under itself or one of its own descendants with `CYCLIC_MOVE`, an unknown `itemId` with `NODE_NOT_FOUND`, and a resulting depth past `maxDepth` with `MAX_DEPTH_EXCEEDED`. See [Layout writes and navigation](#layout-writes-and-navigation). Returns `itemId` and the new `draftUpdatedAt`.                                                                                                                                                                                                                                                                      |
| `remove_nav_item`          | `admin:pages:write`       | Deletes a navigation item and its whole subtree, addressed by `itemId`. Refuses an unknown `itemId` with `NODE_NOT_FOUND`. See [Layout writes and navigation](#layout-writes-and-navigation). Returns the removed `itemId` and the new `draftUpdatedAt`.                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `search_pages`             | `admin:pages:read`        | Searches/filters pages (status, text, locale) with pagination. Returns list rows — no component trees.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `get_page`                 | `admin:pages:read`        | Returns a full page: `componentTree` with node IDs, SEO, and the `updatedAt` token needed for edit operations.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `suggest_internal_links`   | `admin:pages:read`        | Semantic internal-link suggestions (OpenAI embeddings). Pass `pageId` or `text` + `locale`; returns `outbound` targets and, for a page, `inbound` candidates with `path`, `score` and `alreadyLinked`. Needs the **Internal links** scan to have run once (`indexed: false` otherwise) and the `ai-internal-links` feature.                                                                                                                                                                                                                                                                                                                                                                              |
| `create_page`              | `admin:pages:write`       | Creates a new draft page. Slug must start with `/`. Returns the new page ID and its `updatedAt` token.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `delete_page`              | `admin:pages:write`       | Permanently deletes a page. Requires `expectedUpdatedAt` (from `get_page`). A live page cannot be deleted, regardless of `status` — unpublish or archive it first. See [Deleting and renaming pages](#deleting-and-renaming-pages).                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `update_page`              | `admin:pages:write`       | Renames a page and/or changes its slug. Requires `expectedUpdatedAt`. A title change reverts a published page to draft; a slug change does not. See [Deleting and renaming pages](#deleting-and-renaming-pages).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `validate_page`            | `admin:pages:read`        | Dry-runs block validation for a page. Returns dotted error paths so you can fix props before publishing.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `add_block`                | `admin:pages:write`       | Inserts a new block. Server seeds `defaultProps` and generates the node ID. Returns the node ID and the new `updatedAt`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `update_block_props`       | `admin:pages:write`       | Patches a block's props by node ID with a **shallow** merge, then re-validates that block. A key cannot be removed — `null` is stored as a value. See [Patching block props](#patching-block-props). Returns the new `updatedAt`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `move_block`               | `admin:pages:write`       | Re-parents or reorders a block by node ID. Returns the new `updatedAt`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `remove_block`             | `admin:pages:write`       | Deletes a block and its entire subtree by node ID. Returns the new `updatedAt`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `update_seo`               | `admin:pages:write`       | Sets a page's SEO metadata (meta title/description, canonical, robots, Open Graph). Requires `expectedUpdatedAt`; returns the new `updatedAt`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `list_collections`         | `admin:pages:read`        | Lists collections with custom field schemas, `urlPrefix`, `layoutLocked`, and entry counts. Call before creating entries.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `create_collection`        | `admin:pages:write`       | Creates a new collection. Slug and `urlPrefix` must be unique and `urlPrefix` must not be a reserved route, and must not already contain standalone pages (a page at the prefix itself is fine — that is the listing page). Returns the new collection's id, name, slug, and `urlPrefix`.                                                                                                                                                                                                                                                                                                                                                                                                                |
| `add_collection_field`     | `admin:pages:write`       | Adds one new field to an existing collection's schema. `required` has no default — state it explicitly. A field with a `defaultValue`/`defaultNow` is backfilled into every existing entry; a collection above 10,000 entries is refused with `COLLECTION_TOO_LARGE_TO_MIGRATE` rather than migrated. Refuses with `COLLECTION_FIELD_EXISTS` for a duplicate key, `COLLECTION_TOO_MANY_FIELDS` at the 50-field cap, `COLLECTION_REQUIRED_FIELD_NEEDS_DEFAULT` for a required field with no valid default on a non-empty collection, and (retryable) `STALE_COLLECTION` on a concurrent write — see [Collections](#collections). Returns the collection id, the field key, and the new total field count. |
| `update_collection_data`   | `admin:pages:write`       | Patch-merges a collection entry's custom field values (validated against the collection schema). Requires `expectedUpdatedAt`; returns the new `updatedAt`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `search_assets`            | `admin:assets:read`       | Lists/searches media assets. Each row carries `url` (for block image props), `storageKey`, `size`, `width`/`height`, `folderPath` and `updatedAt` (the token `delete_asset`/`update_asset` require); the asset id is the value for collection image/file fields.                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `upload_asset_from_url`    | `admin:assets:write`      | Fetches a public https URL server-side into the media library (SSRF-guarded, `importKey`-idempotent, content-hash deduped). Returns `assetId`, `url`, `storageKey`, `folderPath` and `dedupedBy`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `delete_asset`             | `admin:assets:write`      | Permanently deletes an asset and its variants. Requires `expectedUpdatedAt` (from `search_assets`). Refuses with `ASSET_IN_USE` when anything still references it, listing the pages/entries in the error's `details.usages` — there is no force option.                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `update_asset`             | `admin:assets:write`      | Renames an asset, sets its alt text, and/or moves it to a different folder (creating the folder tree if needed; pass `folderPath: null` to move to the root). Requires `expectedUpdatedAt` (from `search_assets`); renaming does not change the asset's URL.                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `list_data_models`         | `admin:data-models:read`  | Lists the tenant's data models — the reference data blocks point at (authors, venues, service hours). Returns each model's key, name, and display field. Call `get_data_model` for a model's field schema before writing records.                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `get_data_model`           | `admin:data-models:read`  | Returns one data model's field schema (`fieldsSchema`), display field, and description. Read this before `create_data_model_record` or `update_data_model_record`: record data is validated against this schema, so guessing field names fails the write.                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `list_data_model_records`  | `admin:data-models:read`  | Lists a data model's records, newest first, with optional text search. Returns `records` and a `total` count; each record carries an `updatedAt` token to pass to `update_data_model_record`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `get_data_model_record`    | `admin:data-models:read`  | Fetches one record of a data model by its id or by its stable key. Returns the record's data plus the `updatedAt` token required by `update_data_model_record`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `create_data_model_record` | `admin:data-models:write` | Adds one record to a data model. `data` is validated against the model's field schema (see `get_data_model`). `key` is an optional stable identifier, lowercase letters, digits, and hyphens, unique within the model. Returns the new record and its `updatedAt` token.                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `update_data_model_record` | `admin:data-models:write` | Replaces a record's `data` (and optionally its `key`). Pass `expectedUpdatedAt` from `get_data_model_record` or a prior edit; a mismatch returns `STALE_RECORD` so you can re-read rather than overwrite someone's change. Returns the updated record and its new `updatedAt` token.                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `publish_page`             | `admin:pages:publish`     | Publishes a page. Runs the full block-validation gate — use `validate_page` first if unsure. Returns the new `updatedAt`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `unpublish_page`           | `admin:pages:publish`     | Returns a published page to draft. Returns the new `updatedAt`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `publish_layout`           | `admin:pages:publish`     | Publishes a layout's draft header/footer to production. Promoting an unchanged draft is not an error: `promoted` comes back `false` and no CDN purge fires. Counts against the `mcp:publish` budget, the same as `publish_page`/`unpublish_page`. See [Layout writes and navigation](#layout-writes-and-navigation).                                                                                                                                                                                                                                                                                                                                                                                     |

## Linking a new page

1. `create_page` → `add_block` / `update_block_props` with the content.
2. `suggest_internal_links` with the new `pageId` — the draft text is embedded on the fly and compared with every published page in the same locale.
3. For each `outbound` suggestion, insert a link with `update_block_props` (rich-text `content` props take an `<a href="{path}">`, URL props take `path` directly).
4. `publish_page`. Publishing re-indexes the page, so the next call sees it as a target for other pages.

`inbound` lists pages that should link _to_ your page — useful when the new page would otherwise be an orphan.

## The `expectedUpdatedAt` edit loop

All write operations that modify an existing page require an `expectedUpdatedAt` token — including `update_seo`. This token is an optimistic-locking guard: if another agent or the dashboard saves the page between your read and your write, the server rejects the request with a `STALE_PAGE` conflict error.

Every successful edit response carries the page's new `updatedAt`, so you can chain edits without an extra `get_page` between writes:

```
1. create_page(...) → note the returned `updatedAt`
     (or get_page(pageId) for an existing page)
2. add_block / update_block_props / move_block / remove_block / update_seo
     with expectedUpdatedAt = <updatedAt from the previous step>
3. the edit response carries a fresh `updatedAt` → use it for the next edit
4. on a STALE_PAGE conflict, call get_page again and retry from its token
```

Never reuse a stale `updatedAt` across multiple writes; always use the value returned by the most recent read or edit.

### Editing a published page reverts it to draft

Any content write to a page whose `status` is `published` (or `approved`) sets its `status` to `draft`: `add_block`, `update_block_props`, `move_block`, `remove_block`, `update_seo`, `update_collection_data`, and `update_page` with a `title`. (A slug-only `update_page` is the one write that doesn't.) A write that leaves the content exactly as stored — the same props, SEO, collection data or title — changes nothing: the status stays, no version is recorded and `updatedAt` does not move. The page is **not** taken offline — the last published version keeps being served at its slug — but your edits are not live until you call `publish_page`, and the dashboard now lists the page as a draft. The block and SEO tools return only `updatedAt`, not the new `status`, so don't infer liveness from a write's response: when a batch of edits to a live page is done, publish it.

### Patching block props

`update_block_props` merges `propsPatch` into the block's existing props **one level deep**: `{ ...props, ...propsPatch }`. Two consequences:

- **Arrays and objects are replaced, not merged.** To change one item of a list prop (an FAQ entry, a repeater row), read the block with `get_page`, edit the array, and send the **whole** array back.
- **A key cannot be removed.** There is no "unset" operation, and a key you omit is simply left as it was. Sending `null` for an optional field is accepted, but `null` is **stored as the value** — it does not delete the key or restore the default. The block component then receives `null` (not `undefined`), so a default parameter or a schema `.default(...)` does not kick in, and a component that isn't written to handle `null` may render the literal empty state or fail. `null` on a required field is rejected with `BLOCK_VALIDATION_FAILED`.

To "clear" a field, write its empty value of the right type instead — `""` for text, `[]` for a list, `false` for a switch — or the field's default from `get_block_schema`'s `defaultProps`. To get rid of a stray key entirely, remove the block and add it again.

## Deleting and renaming pages

Both `delete_page` and `update_page` require `expectedUpdatedAt` — get it from `get_page`, same as any other edit — and a page's `locale` cannot be changed after creation by either tool.

### `delete_page`

`delete_page(pageId, expectedUpdatedAt)` permanently deletes a page; this cannot be undone.

**A live page cannot be deleted at all.** The server refuses with `CANNOT_DELETE_PUBLISHED` (422) whenever the page still has a published version, regardless of how you call it and regardless of `status`. That distinction matters: any edit to a published page (including `update_page` itself) reverts its `status` to `draft` without clearing what was published, so the page keeps being served live at its slug even though it now reads as a draft — and `delete_page` still refuses it. To make such a page deletable, take it offline first: `unpublish_page` works even when `status` is already `draft` (it clears the live pointer either way), or archive it from `published`. Once nothing is live, `delete_page` succeeds and there is nothing left to purge.

### `update_page`

`update_page(pageId, expectedUpdatedAt, title?, slug?)` takes a `title`, a `slug`, or both. The two fields route through different service logic and **behave oppositely** with respect to a published page:

- A **slug** change enforces the collection's `urlPrefix` when the page is a collection entry and propagates the new slug to the published version — so a published page **stays live**, just under a new URL. It also _tries_ to create a redirect from the old slug, but this is not guaranteed: `updateSlug` silently skips it when another page (in any locale) still uses the old slug, or when a redirect from that source already exists. The response's `redirectCreated` field tells you whether one was actually made — check it instead of assuming an old link now redirects, or an indexed URL will start 404ing.
- A title change reverts a published page to draft, the same as editing any other content on it. There is no separate warning for this — the response's `status` field is the only signal, so always check it after calling `update_page` with a `title`.

If you pass both `title` and `slug` in one call, the title is written first, then the slug. That's not because `updateSlug` needs to read the stored slug — a title-only write never touches the slug, so `updateSlug` would see the same original value either order — it's so the slug write's result, including the fresh `updatedAt` and whether a redirect actually landed, is what the response reflects.

## Collections

Collection entries are pages that belong to a collection and carry structured custom fields (`collectionData`).

Collections can be **created and extended** through MCP, but never **updated or deleted** — there is no `update_collection`, no `delete_collection`, and no way to change or remove an existing field once it has been added. This is a deliberate design decision: an agent sending a short `fieldsSchema` array to replace the schema would silently delete fields and take the meaning of existing entry data with them.

**Prefer passing the complete `fieldsSchema` to `create_collection`** when creating a new collection, rather than building the schema up afterward with repeated `add_collection_field` calls — `add_collection_field` is for extending a collection that already exists. Building a schema one field at a time is the riskier path: it can run into the 50-field cap partway through, and it races. MCP clients may dispatch tool calls concurrently, and two `add_collection_field` calls against the same collection do not both land — the loser is refused with `STALE_COLLECTION` (see below) rather than silently dropping a field. So `add_collection_field` calls against the same collection must be issued sequentially, never in parallel; on a `STALE_COLLECTION` refusal, re-read the collection and retry.

1. `create_collection` → name, slug, `urlPrefix`, and — preferably — the complete `fieldsSchema` for the collection in the same call. Slug and `urlPrefix` must each be unique across the tenant; `COLLECTION_SLUG_TAKEN` and `COLLECTION_URL_PREFIX_TAKEN` are distinct refusals, so a retrying agent knows which value to change. Some `urlPrefix` values are reserved — `/api`, the dashboard routes, the admin routes and the auth routes — and are refused independently of any conflict with another collection.
2. `add_collection_field` → adds one new field to an existing collection's schema, for a collection that wasn't given its full schema up front. `required` has no default and must be stated explicitly on every new field. `COLLECTION_FIELD_EXISTS` means that key is already taken. A field carrying a `defaultValue` or `defaultNow` is backfilled into every existing entry _and_ into stored page versions; a collection above 10,000 entries is refused with `COLLECTION_TOO_LARGE_TO_MIGRATE` rather than migrated inline — the guard is `entryCount > MIGRATION_MAX_ENTRIES`, so a collection with exactly 10,000 entries is **not** refused, only one with more. Three more refusals guard this call:
   - **`COLLECTION_TOO_MANY_FIELDS` (422)** — the collection already holds 50 fields (`MAX_COLLECTION_FIELDS`). Refused before any write is attempted.
   - **`COLLECTION_REQUIRED_FIELD_NEEDS_DEFAULT` (422)** — thrown only when all three hold: the new field is `required: true`; its effective default (`defaultNow` on a date/datetime field resolves to the current date/datetime, otherwise `defaultValue`) is either absent or fails the same validator existing entries are judged by; and the collection already has at least one entry. An empty collection always accepts the field — there is nothing to backfill yet. In practice: a required `url` default must be a real URL (`""` is refused), a required `multiselect` default must select at least one option (`[]` is refused), `validation.min`/`max`/`pattern` apply to the default the same as to any entry value, and `image`/`file`/`relation`/`dataModelRef` defaults must be valid UUIDs. `defaultNow` always passes. A `dataModelRefs` field has no default — its stored value is always an array of record UUIDs, filled in per entry.
   - **`STALE_COLLECTION` (409)** — the collection's `updatedAt` changed between this call's read and its write, typically because another `add_collection_field` call landed first. This is the only one of the three that is retryable: re-read the collection and try the call again. It exists because two concurrent `add_collection_field` calls would otherwise silently drop one of the fields.
3. `list_collections` → pick the collection, read its `fields` (key, type, required, options).
4. For image/file fields, get an **asset id** (not a url) via `search_assets` or `upload_asset_from_url`. Delivery rewrites it to a `/cdn/` url when it serves the entry, on both `pages/by-slug` and `collections/<slug>/entries`.
5. `create_page` with `collectionId` + `collectionData`. Field defaults fill themselves; the collection's `urlPrefix` is auto-prepended to your slug (the response carries the final slug).
6. `update_collection_data` patch-merges field changes later (same `expectedUpdatedAt` loop as block edits). Patching a published entry reverts it to draft (the response carries the new `status`).
7. `search_pages` with `collectionId` lists a collection's entries.

Collections with `layoutLocked: true` reject block edits on their entries (`LAYOUT_LOCKED`) — only collection data and SEO are editable. Publishing an entry re-validates `collectionData` against the collection's **current** schema and fails with `COLLECTION_DATA_INVALID` if the schema evolved since the entry was written; `validate_page` reports the same problems as `collectionErrors` before you publish.

### Reserved field keys

A field's `key` cannot be `title`, `slug`, `status`, `id`, `locale`, `published_at`, `created_at`, or `updated_at` — every one of them is already a built-in page column (used by `sort_field`, the delivery API, and the rest of the CMS), and a collection field can't reuse one. Both `create_collection`'s `fieldsSchema` and `add_collection_field`'s `field` are checked against the same list.

This refusal is a schema-validation error, not a coded `ServiceError` — the Zod schema the MCP tool declares for its input checks it directly, so the call never reaches the service layer. It arrives as a JSON-RPC `InvalidParams` error, not an `isError: true` tool result with a `code` field to branch on. Reaching for `title` first when building a Blog collection is the obvious mistake this exists to catch — use something like `heading` or `post_title` instead.

## Asset uploads and folders

`upload_asset_from_url` fetches public https URLs only (private/internal addresses are rejected) and returns everything needed to use the asset immediately:

| Field                  | Use it for                                                                                                                                                                           |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `url`                  | The value for a **block** image prop — `/cdn/<storageKey>`, rewritten by the tenant's asset proxy.                                                                                   |
| `storageKey`           | The bare key behind that url, if you need to build the path yourself.                                                                                                                |
| `assetId`              | The value for a **collection** `image`/`file` field.                                                                                                                                 |
| `width`, `height`      | Intrinsic dimensions, when known.                                                                                                                                                    |
| `folderPath`           | Where the asset landed, so you can confirm the tree you asked for.                                                                                                                   |
| `deduped`, `dedupedBy` | Whether this call created an asset (`false`), matched a previous `importKey` (`"importKey"` — nothing was downloaded), or matched identical bytes after the fetch (`"contentHash"`). |

`search_assets` rows carry the same `url`, `storageKey`, `width`, `height` and `folderPath`, plus `size` and the `updatedAt` token that `delete_asset` and `update_asset` require.

**The two forms are not interchangeable.** A block image prop holds the _url_; a collection `image`/`file` field holds the _asset id_, which delivery resolves to a `/cdn/` url when it serves the page. If you write a bare asset id into a block prop by mistake, `add_block`/`update_block_props` resolve it to that asset's url before validating, so the write still succeeds; an id that doesn't resolve to one of this tenant's assets is left as-is and rejected with `BLOCK_VALIDATION_FAILED`.

### Idempotent imports

Pass `importKey` to make a re-run cheap: the key is looked up **before** the fetch, so a resumed import re-downloads nothing. It defaults to the source URL with its fragment stripped, which is usually what you want; pass your own when the source URL is unstable (a signed or expiring link). A hit returns the existing asset with `deduped: true` and `dedupedBy: "importKey"`, and **never moves it between folders**.

Content-hash dedup still runs after the fetch, so byte-identical files collapse even without a key — but a source that re-encodes on every request (some galleries do) produces different bytes each time, and only `importKey` makes that idempotent.

### Folders

Always pass `folderPath` so the media library stays organized, in the format `(collection name)/(page title)` for entries — e.g. `Blog/My Launch Post` — or `Pages/(page title)` for standalone pages.

`folderPath` builds a **real nested tree**: each segment is created or matched under the previous one, so `Gallery/2026/Palm Sunday` yields three levels and two albums with the same title under different years stay apart. Segments are matched by slug, so the same path always resolves to the same folder. A path exceeding the maximum depth of 10 is rejected.

## Deleting and moving assets

`delete_asset(assetId, expectedUpdatedAt)` permanently deletes an asset: it removes the S3 original (leaving the database row intact to retry if that fails), makes a best-effort attempt to delete every generated variant, then deletes the database row regardless of whether the variant deletes succeeded. Variant-delete failures aren't checked or retried, so a variant object can occasionally outlive the asset it belonged to. This cannot be undone. The token comes from `search_assets`, the only MCP read that returns an asset's `updatedAt`.

**There is no force delete through MCP.** If anything still references the asset, the server refuses with `ASSET_IN_USE` (409) instead of deleting it, and lists what references it — pages, collection entries, data-model records, and the tenant's branding logo, whichever apply — in the error's `details.usages`, capped at the first 20 entries; `details.total` carries the true count, so you can tell whether the list you got is the whole story. That list is the point: it's how you tell apart three identically-named files when only one of them is actually the one in use. Remove or re-point those references first and delete again; there is no `force` flag exposed through this tool.

`delete_asset` and `update_asset` share the same `expectedUpdatedAt` guard as the page tools, but a stale token means something different here. On a page, `STALE_PAGE` almost always means someone else edited it. On an asset, `STALE_ASSET` (409) usually doesn't: background jobs write to an asset row after upload — image-variant generation fills in `width`, `height`, `processingStatus` and `variants`; the AI alt-text path fills in `aiSuggestedAlt` and sometimes `alt` — and any of those bumps `updatedAt` on their own, so this refusal fires routinely with no human edit involved. The refusal carries the asset's _current_ `filename`, `alt`, `folderPath`, `size` and `storageKey` plus the new `updatedAt` — every field that can actually tell you something changed here, not the full `search_assets` row. `width` and `height` are left out on purpose: the image-processing worker writes them after upload, so a difference there would read as "someone replaced the file" when it's really just processing catching up — exactly the misleading signal this refusal exists to avoid. Compare the fields you do get against the row you read. **If `size` or `storageKey` differ, the file itself was replaced** (a replace swaps the bytes, the storage key, the size and the dimensions while leaving filename, alt and folder alone); that is a real human edit, so don't retry a delete blind. If they all match, nothing either tool can change differs and retrying with the new `updatedAt` is normally safe — with one caveat the payload cannot close: a focal-point or per-locale alt-text edit (`focalPoint`, `altTexts`) shows up in neither the refusal nor `search_assets`, so a match does not _prove_ the change was machine-generated. `details.processing` is `true` while image processing is queued or still running — the one cause the guard can prove outright, so it's the one it states. It is always `false` for a non-image asset (`processingStatus` is `null` there), so for a PDF the comparison above is the only signal you get.

`update_asset(assetId, expectedUpdatedAt, filename?, alt?, folderPath?)` renames the asset, sets its alt text, and/or moves it to a folder — pass any combination of the three. `folderPath` creates the folder tree if it doesn't already exist (same nested-path rules as `upload_asset_from_url`); pass `folderPath: null` to move the asset back to the root. Renaming never changes the asset's delivery URL: the URL is derived from `storageKey`, and `updateAssetSchema` has no field that can touch it.

A rename and a move are two separate writes, so the order matters. The folder path is resolved (and validated — depth, segment names) **before** the rename is written, so an unusable `folderPath` refuses with nothing applied. If the move still fails after the rename committed, the tool answers `PARTIAL_UPDATE` instead of a plain failure: `details.renameApplied` is `true`, `details.originalCode` carries the real error, and `details.updatedAt` is the post-rename token — retry `update_asset` with just the `folderPath` and that token, no `search_assets` needed.

## Block registry sync

The block registry (types, schemas, slots) lives in your tenant app. The CMS keeps a **snapshot** of it, and refreshes that snapshot **only when someone opens a page in the visual editor** — every successful editor load re-sends the registry. Deploying your tenant app does not refresh it, and no MCP tool can: an agent cannot trigger the sync itself. A person has to open the editor.

**After every deploy that adds a block, adds or changes a field, or changes slots, open any page in the visual editor once** (against the deployed app — the editor loads whatever the **Frontend URL** setting points at), wait for it to load, then close it. Until then the MCP tools keep working from the old snapshot:

- `whoami` still reports the old `blockCount` and `schemaSyncedAt` — compare `schemaSyncedAt` against your deploy time to tell whether the snapshot is current;
- `list_block_types` doesn't list the new block, and `get_block_schema` / `add_block` refuse it with `BLOCK_TYPE_NOT_FOUND` (404);
- `validate_page` and `publish_page` report a page containing the new block as `BLOCK_VALIDATION_FAILED` ("Unknown block type … not in tenant registry"), and a field whose type changed is validated against its **old** type;
- a brand-new prop on an existing block is _not_ refused — props the snapshot doesn't know are stored without validation.

If `whoami` reports `blockRegistry: null`, the editor has never been opened for this tenant: the block tools, `validate_page` and `publish_page` refuse with `REGISTRY_NOT_SYNCED` (422) until it is.

## Layout registry sync

Layout _schemas_ — the header/footer field descriptors a tenant app declares with `defineLayout()` — reach the CMS the same way the block registry does: a snapshot re-sent every time someone opens the visual editor, never read from the tenant app live and never refreshed by a deploy. After a deploy that adds or changes a layout field, open the editor once; until then `set_layout_field` refuses the new field with `FIELD_PATH_NOT_FOUND` and validates changed fields against their old type. `list_layouts`, `get_layout_schema`, and `list_layout_menus` all read this mirror (`list_layout_menus` needs it to know which fields are `menu-tree` fields in the first place), so if a tenant has never opened the editor — no mirror row exists yet — all three refuse with `LAYOUT_REGISTRY_NOT_SYNCED` (422). The fix is the same as for an empty `blockRegistry`: open any page in the visual editor, wait for it to load, then close it.

`get_layout` is the exception: it reads the stored header/footer _data_ straight from the content database and never consults the schema mirror, so it answers even when the registry has never synced.

`get_layout`'s `hasUnpublishedChanges` is **not** scoped to the `section` you asked for — it is `true` if the draft differs from published on _either_ the header or the footer, regardless of which section you queried. Every other field in the response (`draft`, `published`, `draftUpdatedAt`) is section-scoped; this one flag covers the whole layout, both sections.

`list_layout_menus` returns each menu field's `fieldPath` — e.g. `items`, or `drawerSections.0.items` for a menu-tree nested inside a repeater row. That `fieldPath` is the address `add_nav_item`, `update_nav_item`, `move_nav_item`, and `remove_nav_item` take to add, update, move, and remove nodes in that menu — see [Layout writes and navigation](#layout-writes-and-navigation).

**Blind spot: a menu-tree nested under a plain object field is invisible to `list_layout_menus`.** A tenant app can nest a `menu-tree` repeater inside a plain `z.object(...)` field (for example, a `mobileNav: z.object({ sections: [...] })` drawer field) instead of putting it directly under the section or inside a `repeater`. The SDK's serializer has no case for a bare object field, so it collapses the whole wrapper to a single `fieldType: "text"` descriptor with no `items` — and `list_layout_menus` walks descriptors, so a menu nested under one of these is never enumerated; `get_layout_schema` reports the wrapper field itself as `"text"`. `get_layout` is not affected by this: it returns the section's stored data as-is, wrapper and all, so the nested menu's nodes are visible there even though `list_layout_menus` cannot address them and `get_layout_schema` mislabels the field they live in. This is the same blind spot the visual editor's own props panel already has, not something MCP introduced — an `"object"` field type would need changes in five separate enumeration sites plus a new UI control. If you hit this, the fix is on the tenant side: lift the repeater out of the wrapper to the section's top level (or directly into another repeater), which is a shape both tools already handle.

## Layout writes and navigation

Five tools write layout data: `set_layout_field`, and the four navigation tools `add_nav_item`, `update_nav_item`, `move_nav_item`, and `remove_nav_item`. All five take `layoutKey`, an optional `locale` (defaulting to the tenant's default locale, same as the read tools), and `section` (`"header"` or `"footer"`), and all five share the same guards.

**`fieldPath` comes from a different read depending on what you're addressing.** For a menu-tree field, get it from `list_layout_menus`. For anything else, get it from `get_layout_schema`'s `fields`, joining field names and array indices with dots. A path can address:

- a top-level field of the section, of any type (`logoHref`, `ctas`);
- one row of a `repeater` or `array` field, by index (`ctas.0`) — the value replaces the whole row;
- a field inside such a row (`ctas.0.label`), at any depth of repeaters/arrays.

It **cannot** address a key inside a plain object field: the schema has no object field type, so a tenant's nested `z.object` field is a single `"text"` field and `mobileNav.sections` fails with `FIELD_PATH_NOT_FOUND` (see the blind spot below). Nor can it address a node inside a menu-tree by index — use the navigation tools' `itemId`.

**`expectedUpdatedAt` is required on all five and comes from `get_layout`'s `draftUpdatedAt`.** It is `null` for a layout that has no draft yet — pass `null` on the first write to a layout, not an omitted field. **There is one token per layout and locale, shared by the header and the footer** — not one per section. A write to the header changes the token, so a token you read for the footer is stale afterwards (and vice versa): `get_layout(section: "footer")` returns the same `draftUpdatedAt` as `get_layout(section: "header")`. When editing both sections, chain every write — header or footer — off the `draftUpdatedAt` returned by the previous one. Edits made in the dashboard's visual editor move the same token. A stale or mismatched token is refused with `STALE_LAYOUT_DRAFT` (409); the error's `details.draftUpdatedAt` carries the actual current value, so a retry costs one re-read of that field, not a fresh `get_layout` call — though re-reading with `get_layout` is still the safer choice if the draft content itself may have moved on you too. Each of the five write tools returns a fresh `draftUpdatedAt` in its own response, so — as with the page `expectedUpdatedAt` loop — you can chain several edits without a `get_layout` between them.

**`set_layout_field` on a menu-tree field preserves each node's existing `_id`.** The array you pass replaces the whole field, but a node that already carries a string `_id` keeps it — only a node with no `_id` (or a malformed one) gets a freshly generated one. `value` must be an array for a menu-tree field; anything else (an object, `null`, a string) is refused with `INVALID_FIELD_VALUE` rather than silently replacing the menu with nothing. `add_nav_item`, `update_nav_item`, `move_nav_item`, and `remove_nav_item` are the narrower, single-node path: each mutates or relocates one node in place and leaves every other node untouched. `add_nav_item` is the one exception to id preservation — any `_id` you put on its `node` argument (or on any of that node's `children`) is discarded, and a fresh id is minted unconditionally, so pasting a node copied from elsewhere in the tree can never leave two nodes sharing one id.

**`itemId` is the `_id` field on a node inside `get_layout`'s returned draft — not something `list_layout_menus` hands you.** `list_layout_menus` returns `{ fieldPath, label, maxDepth, nodeCount }` for each menu field; it carries no node ids at all. To get an `itemId`, read the section with `get_layout` and take the `_id` off the node you want, from its `header`/`footer` data.

**A draft written before any of these five tools ever touched it may have nodes with no `_id`.** A seed script or an import that never went through `add_nav_item` or `set_layout_field` has no reason to stamp one. The first `add_nav_item` call against that field stamps every node in the array with an id as a side effect of normalizing the tree before inserting into it; a `get_layout` read after that call shows `_id` on every node. Nothing stamps ids on a read — only a write does.

**A `fieldPath` ending on an array index into a menu-tree field is refused by all five write tools**, with `INVALID_FIELD_VALUE`. Menu-tree data is stored as an array, so a path like `items.0` would otherwise resolve exactly like a repeater row — but a menu node's real address is its `_id`, not its position (positions shift under the very edits the navigation tools perform). Address a specific node with the navigation tools' `itemId` parameter instead.

**The four navigation tools refuse a `fieldPath` that isn't a menu-tree field**, with `NOT_A_MENU_TREE`.

**These write tools inherit the same blind spot `list_layout_menus` has, for the same reason.** A menu-tree nested under a plain `z.object` wrapper (see above) is invisible to `list_layout_menus` because the SDK's serializer collapses it to a `"text"` descriptor with no `items` — and these tools resolve `fieldPath` by walking that exact same schema mirror. A `fieldPath` that tries to reach past the collapsed wrapper (e.g. `mobileNav.sections.0.items`) does not resolve to a menu at all; it fails with `FIELD_PATH_NOT_FOUND` at the first segment past the wrapper. The fix is the same one described in [Layout registry sync](#layout-registry-sync): the tenant needs to lift the repeater out of the wrapper.

**The five write tools depend on the schema mirror the same way `list_layouts`, `get_layout_schema`, and `list_layout_menus` do**, and refuse with `LAYOUT_REGISTRY_NOT_SYNCED` (422) until a tenant has opened the visual editor at least once — and after that they validate against whatever the editor last synced, so open the editor again after deploying layout changes. `publish_layout` does not consult the schema mirror at all — like `get_layout`, it works even when the registry has never synced.

**Writes land on the draft. Nothing is live until `publish_layout` runs** — or until some page using that layout is published, which promotes the same draft as a side effect (the same promotion `publish_layout` itself performs).

**`publish_layout` promotes the header and footer together — there is no per-section publish, and no layout version history.** A footer draft left half-edited ships the moment you publish after a header-only change, with nothing to review or roll back afterward: unlike a page, a layout keeps no version snapshots. This is pre-existing behaviour shared with page publishing's own promotion path, not something specific to the MCP tools.

**`publish_layout` on an already-published draft is not an error.** Its response's `promoted` field is `false`, and neither a CDN purge nor an activity-log entry is created, because nothing actually changed. `publish_layout` also counts against the `mcp:publish` rate-limit budget, the same as `publish_page` and `unpublish_page`.

---

Source: https://glokki.com/docs/page-analytics

# Page Analytics

Privacy-friendly, first-party, cookieless pageview analytics for your tenant site — views, unique visitors, referrers, and device breakdown. No cookies, no consent banner, two files of setup.

This guide covers wiring the SDK's `createInsightsRoute` proxy and the React `PageAnalytics` beacon into your tenant app. The collected data shows up on the CMS `/analytics` dashboard. For the rest of the delivery integration, see the [Integration Guide](/docs/integration).

---

## Table of Contents

1. [What it is](#what-it-is)
2. [Prerequisites](#prerequisites)
3. [Setup step 1 — add the proxy route](#setup-step-1--add-the-proxy-route)
4. [Setup step 2 — drop in the component](#setup-step-2--drop-in-the-component)
5. [How it handles the hard cases](#how-it-handles-the-hard-cases)
6. [Viewing data](#viewing-data)
7. [FAQ](#faq)

---

## What it is

First-party, **cookieless** pageview analytics. When a visitor loads a page on your site, a tiny client-side beacon reports the path and referrer to a proxy route you host on your own domain, which forwards it to the CMS. Because nothing is stored in a cookie or `localStorage`, and visitors are never tracked across days, **no consent banner is required**.

It captures:

- **Views** — total pageviews.
- **Unique visitors** — distinct visitors, counted per day (see [Privacy](#privacy)).
- **Referrers** — where traffic came from.
- **Device** — desktop / mobile / tablet, derived server-side from the User-Agent.

Two pieces do the work:

- **`createInsightsRoute`** (from `@glokki-cms/sdk`) — a same-origin route handler you mount in your app. It holds the delivery API key server-side and forwards beacons to the CMS ingest endpoint.
- **`PageAnalytics`** (from `@glokki-cms/react`) — a client component that renders `null` and fires the beacon on mount and on every client-side route change.

---

## Prerequisites

1. **A delivery API key** with the `delivery:read` scope. Create one in the CMS Dashboard → **Settings → API Keys**. This key is used **server-side only** by the proxy route — it is never sent to the browser.
2. **Your tenant slug** (the same slug you use elsewhere in the delivery integration).
3. The **base URL** of your CMS instance (e.g. `https://cms.example.com`).

Store them as environment variables. The names below are illustrative — there is no canonical naming convention enforced by the SDK, so use whatever your app already uses; just pass the right value into each config field:

```bash
CMS_URL=https://cms.example.com
CMS_TENANT=acme
CMS_API_KEY=ek_live_...
```

---

## Setup step 1 — add the proxy route

Create a route in your app that re-exports the handler from the SDK. Mounting it on a **neutral, same-origin path** (the default the component expects is `/_e`) is what makes the analytics first-party and adblock-resistant.

```ts
// app/_e/route.ts
import { createInsightsRoute } from "@glokki-cms/sdk"

export const { POST } = createInsightsRoute({
  baseUrl: process.env.CMS_BASE_URL!,
  tenantSlug: process.env.CMS_TENANT!,
  apiKey: process.env.CMS_API_KEY!,
})
```

Config fields:

| Field        | Description                                                                                                                                                                                   |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `baseUrl`    | The base URL of your CMS instance. The handler appends the ingest path itself.                                                                                                                |
| `tenantSlug` | Your tenant slug, sent upstream as `X-Tenant-Slug`.                                                                                                                                           |
| `apiKey`     | A delivery API key with the `delivery:read` scope. **Stays server-side** — it is read from the request handler, never exposed to the browser.                                                 |
| `ipHeaders`  | _Optional._ Ordered header names to read the visitor IP from. Defaults cover Cloudflare (`cf-connecting-ip`), Fastly (`fastly-client-ip`), `x-real-ip`, then the first `x-forwarded-for` hop. |

The handler forwards the beacon body to `POST /api/v1/delivery/analytics` on the CMS, attaching the visitor's IP and User-Agent so the CMS can derive uniques and device. It **always returns `204` immediately** and swallows upstream failures — analytics can never break or slow down your page.

---

## Setup step 2 — drop in the component

Render `<PageAnalytics />` once, high in your app — the root layout is fine — so it counts **only real visitor page views**:

```tsx
// app/layout.tsx
import { PageAnalytics } from "@glokki-cms/react"

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <PageAnalytics />
      </body>
    </html>
  )
}
```

Since `@glokki-cms/react` 0.10.0 the component stays silent when the CMS, not a visitor, opens the page — the CMS only filters out bots, so these would otherwise inflate views:

- every load inside the **CMS visual editor**, and the **editor preview route** used to edit block templates — the CMS opens both with `?_editor=1`;
- **draft previews** opened from the CMS (`?preview=true&token=...`).

If your app uses a different preview query parameter than the `preview=true` convention from the [Integration Guide](/docs/integration#10-preview-mode), don't render `<PageAnalytics />` on those views yourself. Pageviews already recorded from editing sessions stay in the stats.

### With `@glokki-cms/react` older than 0.10.0

Older versions fire on every page they are mounted on. Upgrade, or wrap the component in a client gate and mount `<Analytics />` inside `<Suspense fallback={null}>` (`useSearchParams()` needs the boundary, or Next.js opts the whole layout out of static rendering):

```tsx
// components/analytics.tsx
"use client"

import { PageAnalytics } from "@glokki-cms/react"
import { EDITOR_QUERY_PARAM } from "@glokki-cms/sdk"
import { useSearchParams } from "next/navigation"

export function Analytics() {
  const searchParams = useSearchParams()
  // The CMS opens the editor iframe and the editor preview route with ?_editor=1.
  const inEditor = searchParams.get(EDITOR_QUERY_PARAM) === "1"
  const inPreview = searchParams.get("preview") === "true"
  return inEditor || inPreview ? null : <PageAnalytics />
}
```

The component renders `null`. On mount and on every client-side route change it fires a single `navigator.sendBeacon` (falling back to a `keepalive` `fetch` on browsers without it), so the request survives navigation and never blocks render.

It accepts one optional prop:

| Prop       | Default | Description                                                                                                                                              |
| ---------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `endpoint` | `"/_e"` | The same-origin path of your proxy route. **Must match where you mounted `createInsightsRoute`.** Override it on both sides if you use a different path. |

---

## How it handles the hard cases

### CDN caching (Fastly / Cloudflare)

The beacon is **client-side**. It runs in the visitor's browser after the (possibly CDN-cached) HTML is delivered, so it always reaches your proxy and is counted — even for a fully edge-cached page. Server-side counting would miss every cache hit; this approach does not.

### Adblockers

Serving the proxy from your **own origin** on a **neutral path** (`/_e`) sidesteps the domain- and path-based blocklists that target third-party analytics scripts, so the block rate is low. But be honest: any client-side analytics can be blocked, so expect a small undercount. This is not, and cannot be, 100% adblock-proof.

### Privacy

Visitors are identified by a **daily-rotating, salted SHA-256 hash of IP + User-Agent**, computed on the CMS at ingest time. The **raw IP is never stored** — it is consumed to compute the hash and discarded. Because the salt rotates every day, today's hashes cannot be correlated with yesterday's: there is **no durable cross-day identifier**, "unique visitor" means unique-per-day, and **no cookie/consent banner is required**.

---

## Viewing data

Open the **`/analytics`** dashboard in the CMS. It shows views, unique visitors, top referrers, and device breakdown, with **7-day, 30-day, and 90-day** ranges and a trend chart. No extra configuration — pageviews appear as soon as the proxy and component are live.

---

## FAQ

**Do I need a cookie consent banner?**
No. Nothing is written to cookies or `localStorage`, and the visitor identifier rotates daily with no cross-day correlation, so there is no persistent tracking identifier.

**The proxy returned `204` but I see no data. What's wrong?**
The proxy deliberately returns `204` and swallows upstream errors so analytics never breaks the page. Check that `baseUrl`, `tenantSlug`, and a valid `delivery:read` `apiKey` are set, and that `<PageAnalytics endpoint>` matches the path where you mounted the route.

**Can I mount the proxy somewhere other than `/_e`?**
Yes — mount it anywhere and pass the same path to `<PageAnalytics endpoint="/your-path" />`. Keep it same-origin so the analytics stays first-party.

**Will this slow down my pages?**
No. The component fires a non-blocking `sendBeacon`, and the proxy responds immediately without waiting on the CMS.

**Why are my numbers higher than in my other analytics tool?**
A common cause is a `@glokki-cms/react` version older than 0.10.0, which also counts page loads inside the CMS editor, draft previews and the editor preview route. Upgrade, or gate it as shown in [Setup step 2](#with-glokki-cmsreact-older-than-0100).

---

Source: https://glokki.com/docs/sitemap-and-robots

# Sitemap and robots.txt

How to serve /sitemap.xml and /robots.txt from your tenant app — using the sitemap the CMS already generates, and a robots.txt that keeps the editor preview route and non-production deployments out of search engines.

The CMS builds an XML sitemap of every published, indexable page in your tenant and serves it from the Delivery API. Your app only needs a thin route that fetches it server-side and re-serves it from your own domain, plus a `robots.txt` that points crawlers at it. For the rest of the delivery integration, see the [Integration Guide](/docs/integration).

---

## Table of Contents

1. [The Sitemap Endpoint](#1-the-sitemap-endpoint)
2. [Serving `/sitemap.xml`](#2-serving-sitemapxml)
3. [Serving `/robots.txt`](#3-serving-robotstxt)
4. [Verifying the Setup](#4-verifying-the-setup)
5. [Troubleshooting](#5-troubleshooting)

---

## 1. The Sitemap Endpoint

```
GET {CMS_BASE_URL}/api/v1/delivery/sitemap.xml
Authorization: Bearer <delivery API key>
X-Tenant-Slug: <your tenant slug>
```

There is no SDK client method for it — call it with `fetch` from server-side code. It uses the same `CMS_BASE_URL`, `CMS_TENANT`, and `CMS_API_KEY` values as the rest of the integration; the key needs the `delivery:read` scope and must never be sent to the browser.

### Query parameters

| Param      | Required | Description                                                                                                                                                                                                                                                                                    |
| ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `base_url` | No       | The origin used to build every `<loc>` URL, e.g. `https://acme.com`. Trailing slashes are stripped. When omitted, the CMS uses your **Frontend URL** (CMS Dashboard → **Settings → Visual Editor → Frontend URL**). If neither is set, the endpoint returns `422 FRONTEND_URL_NOT_CONFIGURED`. |

### What it contains

- **Only published pages.** The sitemap is built from each page's published version, so unpublished drafts and unsaved edits never appear. Collection entries that are published as pages are included too.
- **No `noindex` pages.** A page whose published robots directive contains `noindex` (the _No Index, …_ options in the page's SEO settings) is left out.
- **`<lastmod>`** — the date (`YYYY-MM-DD`) the page was last published.
- **hreflang alternates.** When pages with the **same slug** exist in several locales, each `<url>` lists every locale as an `<xhtml:link rel="alternate" hreflang="...">`, plus an `x-default` entry pointing at the `en` version.

> **Locale URLs follow a fixed scheme.** In the sitemap, `en` pages are at the root (`https://acme.com/about`) and every other locale gets a path prefix (`https://acme.com/de/about`). Your tenant's configured default locale and locale URL strategy are not applied to the sitemap, and translations with different slugs (`/about` vs `/de/ueber-uns`) are not linked as alternates. If your site is single-locale in `en`, or uses exactly this scheme, nothing needs doing. Otherwise, post-process the XML in your route (see [section 2](#2-serving-sitemapxml)) or build the sitemap yourself.

### Response

| Header          | Value                                                                         |
| --------------- | ----------------------------------------------------------------------------- |
| `Content-Type`  | `application/xml; charset=utf-8`                                              |
| `Cache-Control` | `public, s-maxage=3600, stale-while-revalidate=86400`                         |
| `ETag`          | A hash of the XML. Send it back as `If-None-Match` to get `304 Not Modified`. |
| `Vary`          | `X-Tenant-Slug, Host`                                                         |

The endpoint counts against your API key's delivery rate limit like every other Delivery API call, and returns `429` when the budget is exhausted — another reason to let a CDN cache your `/sitemap.xml` rather than proxying every crawler hit through to the CMS.

### Why not build it from `pages.list()`?

The SDK's `pages.list()` returns lightweight items — slug, title, locale, `publishedAt` — with no robots directive, no locale alternates, and no last-modified date. Building a correct sitemap from it would mean one `pages.getBySlug()` call per page just to find out which pages are `noindex` and which translations belong together. The sitemap endpoint does that in a single query.

---

## 2. Serving `/sitemap.xml`

Add a route handler that fetches the CMS sitemap server-side and returns it unchanged:

```ts
// app/sitemap.xml/route.ts
const CMS_BASE_URL = process.env.CMS_BASE_URL!.replace(/\/+$/, "")
// Your public origin, e.g. https://acme.com — the host every <loc> will use.
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!

export async function GET(request: Request) {
  const url = new URL(`${CMS_BASE_URL}/api/v1/delivery/sitemap.xml`)
  url.searchParams.set("base_url", SITE_URL)

  const headers: Record<string, string> = {
    Authorization: `Bearer ${process.env.CMS_API_KEY}`,
    "X-Tenant-Slug": process.env.CMS_TENANT!,
  }
  const ifNoneMatch = request.headers.get("if-none-match")
  if (ifNoneMatch) headers["If-None-Match"] = ifNoneMatch

  let upstream: Response
  try {
    upstream = await fetch(url, { headers, cache: "no-store" })
  } catch {
    return unavailable()
  }

  if (upstream.status === 304) {
    return new Response(null, { status: 304, headers: passthrough(upstream) })
  }
  if (!upstream.ok) return unavailable()

  return new Response(upstream.body, {
    status: 200,
    headers: passthrough(upstream),
  })
}

function passthrough(upstream: Response): Record<string, string> {
  const out: Record<string, string> = {}
  for (const name of ["content-type", "cache-control", "etag"]) {
    const value = upstream.headers.get(name)
    if (value) out[name] = value
  }
  return out
}

// A 5xx tells crawlers to come back later instead of treating the sitemap as gone.
function unavailable() {
  return new Response("Sitemap temporarily unavailable", {
    status: 503,
    headers: { "Retry-After": "300", "Cache-Control": "no-store" },
  })
}
```

Notes:

- **Pass `base_url` explicitly.** It ties `<loc>` URLs to the origin this deployment actually serves, independent of what is configured in the CMS. Without it, the Frontend URL from **Settings → Visual Editor** is used.
- **Caching.** The CMS's `Cache-Control` (`s-maxage=3600`, `stale-while-revalidate=86400`) is passed through, so a CDN in front of your app — Vercel's included — caches the sitemap for an hour and refreshes it in the background. Newly published pages appear within that window. The upstream `fetch` uses `cache: "no-store"` so Next.js does not add a second, independent cache layer on top.
- **Errors.** Any upstream failure becomes a `503` with `Retry-After`. Don't turn an upstream `401` or `422` into a `200` with an empty `<urlset>` — crawlers would read that as "this site has no pages".
- **Post-processing.** If you need to adjust the XML (for example to rewrite locale prefixes, see the note in [section 1](#what-it-contains)), read `await upstream.text()`, transform it, and drop the upstream `etag` header since the body no longer matches it.

### Why a route handler and not `app/sitemap.ts`?

Next.js's `app/sitemap.ts` convention (`MetadataRoute.Sitemap`) expects you to return an array of entry objects, which Next.js then serialises to XML. The CMS already returns finished XML, so using `sitemap.ts` would mean parsing that XML back into objects only for Next.js to serialise it again — or assembling the data from `pages.list()` and per-page `getBySlug()` calls, as explained [above](#why-not-build-it-from-pageslist). A route handler at `app/sitemap.xml/route.ts` passes the XML through verbatim, including its `ETag` and cache headers.

Use one or the other: an `app/sitemap.ts` and an `app/sitemap.xml/route.ts` in the same app both claim `/sitemap.xml`.

---

## 3. Serving `/robots.txt`

Use Next.js's `app/robots.ts` convention:

```ts
// app/robots.ts
import type { MetadataRoute } from "next"

const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL! // e.g. https://acme.com

// Must match Settings → Visual Editor → Editor Preview Path in the CMS.
const EDITOR_PREVIEW_PATH = "/_editor-preview"

export default function robots(): MetadataRoute.Robots {
  // Staging, preview deployments, local dev: keep crawlers out entirely.
  if (process.env.APP_ENV !== "production") {
    return { rules: { userAgent: "*", disallow: "/" } }
  }

  return {
    rules: {
      userAgent: "*",
      allow: "/",
      disallow: [EDITOR_PREVIEW_PATH],
    },
    sitemap: `${SITE_URL}/sitemap.xml`,
  }
}
```

What each part does:

- **`Disallow: /_editor-preview`** — the route the CMS loads to edit block templates (see [Editor Preview Route](/docs/editor-preview-route)). It renders nothing outside the editor, so there is nothing for a crawler there. The default path is `/_editor-preview`; if you changed it under **CMS Dashboard → Settings → Visual Editor → Editor Preview Path**, use the same value here.
- **`Sitemap:`** — an absolute URL to the route from [section 2](#2-serving-sitemapxml). Use the same origin you pass as `base_url`.
- **Full block outside production.** `cmsMetadata()` from `@glokki-cms/react` already forces `noindex, nofollow` on every page unless `APP_ENV` is `production`. Keying `robots.txt` off the same variable keeps the two consistent: one setting — `APP_ENV=production` on your production deployment only — opens the site to search engines, and every other environment stays closed. If `APP_ENV` is not set in production, both the meta tags and `robots.txt` will keep the live site out of search results.

Do **not** disallow `/cdn/` or `/og/`. Images under `/cdn/` belong in image search, and social-card crawlers such as Twitterbot honour `robots.txt` — blocking `/og/` (see [OG Image Proxy](/docs/asset-cdn-proxy#7-og-image-proxy)) would break link previews.

A note on combining the two mechanisms: a crawler that is blocked by `robots.txt` never fetches the page, so it never sees its `noindex` meta tag. For a staging host that has never been indexed, blocking is exactly what you want. If a non-production host **has** already been indexed, temporarily allow crawling so search engines can see the `noindex` and drop the pages, then block it again.

Use either `app/robots.ts` or a static `public/robots.txt`, not both — they serve the same URL. The static file cannot switch on `APP_ENV`, so prefer `robots.ts`.

---

## 4. Verifying the Setup

1. **Fetch the sitemap from your site:**

   ```bash
   curl -s https://acme.com/sitemap.xml | head -20
   ```

   Expect `<?xml ...?>`, a `<urlset ...>` element, and one `<url>` with `<loc>` and `<lastmod>` per published page. Every `<loc>` should start with your `NEXT_PUBLIC_SITE_URL`.

2. **Check a `noindex` page is absent.** Set a page to _No Index, Follow_ in its SEO settings, publish it, and confirm its URL is missing from the sitemap (allow up to an hour if a CDN cached the previous version).

3. **Check caching:**

   ```bash
   curl -sI https://acme.com/sitemap.xml | grep -i -E "cache-control|etag"
   # cache-control: public, s-maxage=3600, stale-while-revalidate=86400
   ```

4. **Fetch `robots.txt` on production and on a non-production deployment:**

   ```bash
   curl -s https://acme.com/robots.txt
   # User-Agent: *
   # Allow: /
   # Disallow: /_editor-preview
   #
   # Sitemap: https://acme.com/sitemap.xml

   curl -s https://staging.acme.com/robots.txt
   # User-Agent: *
   # Disallow: /
   ```

5. **Submit the sitemap** in Google Search Console (and Bing Webmaster Tools) once production looks right.

---

## 5. Troubleshooting

### `/sitemap.xml` returns `503`

Your route caught an upstream failure. Call the CMS endpoint directly with the same headers to see the real status:

```bash
curl -i -H "Authorization: Bearer $CMS_API_KEY" -H "X-Tenant-Slug: $CMS_TENANT" \
  "$CMS_BASE_URL/api/v1/delivery/sitemap.xml?base_url=https://acme.com"
```

- `401` — the API key is missing, invalid, expired, or revoked.
- `403` — the API key lacks the `delivery:read` scope.
- `422 FRONTEND_URL_NOT_CONFIGURED` — no `base_url` was sent and no Frontend URL is configured.
- `429` — the delivery rate limit is exhausted; make sure a CDN is caching your `/sitemap.xml`.

### The sitemap is empty

The `<urlset>` has no `<url>` entries when the tenant has no published pages, or every published page is `noindex`. Drafts are never listed — publish the pages.

### `<loc>` URLs use the wrong host

Without `base_url`, the CMS uses the Frontend URL from **Settings → Visual Editor**, which may point at a staging or preview host. Pass `base_url` explicitly from your route.

### Locale URLs don't match my routes

See the locale note in [What it contains](#what-it-contains): the sitemap uses unprefixed URLs for `en` and `/{locale}/` prefixes for everything else.

### Next.js reports a conflict on `/sitemap.xml` or `/robots.txt`

You have two sources for the same URL — `app/sitemap.ts` alongside `app/sitemap.xml/route.ts`, or `app/robots.ts` alongside `public/robots.txt`. Keep one.

---

## Related Guides

- [Integration Guide](/docs/integration) — `CMS_BASE_URL` / `CMS_TENANT` / `CMS_API_KEY`, and `cmsMetadata()` for per-page robots and hreflang tags.
- [Editor Preview Route](/docs/editor-preview-route) — the route `robots.txt` disallows.
- [Asset CDN Proxy](/docs/asset-cdn-proxy) — the `/cdn/` and `/og/` routes that must stay crawlable.

---

Source: https://glokki.com/docs/tenant-cache-postgres

# Tenant-side caching with PostgreSQL

How a tenant app caches Delivery API reads in its own PostgreSQL database, with stale-while-revalidate and webhook-driven invalidation on publish.

How a tenant app caches Delivery API reads in its **own** PostgreSQL database,
with stale-while-revalidate and webhook-driven invalidation on publish. Written
for a Next.js app deployed on **Vercel** (serverless), but the concepts apply to
any host.

> Everything here ships in two published packages (`@glokki-cms/cache`,
> `@glokki-cms/cache-postgres`) plus the invalidation webhook the CMS sends on
> publish. Nothing lives in your repo until you wire it up.

---

## 1. What you get

`createCachedClient()` is a drop-in replacement for the SDK's `createClient()`.
It wraps every Delivery API read in a two-layer cache:

```
request → L1 (in-process memory) → L2 (your Postgres) → Origin (Delivery API)
```

- **L1** — `MemoryStore`, a `Map` in RAM. Fast, but **private to one process
  instance**. Optional (and disabled on serverless — see §4).
- **L2** — your PostgreSQL table `cms_cache`. Slower than RAM, but **shared**
  across every instance of your app.
- **Stale-while-revalidate** — after `softTtl` an entry is still served (as
  "stale") while a single background revalidation runs. Entries are never
  deleted by TTL — only by invalidation or GC.
- **Stale-on-error** — if the origin fails during revalidation, the old entry
  stays. Your site keeps serving the last good content.
- **Tag-based invalidation** — every cached entry records _surrogate tags_
  (`page-<id>`, `delivery-pages:<tenant>`, …). Invalidation = "delete every
  entry carrying any of these tags".

---

## 2. Install

```bash
bun add @glokki-cms/cache @glokki-cms/cache-postgres @glokki-cms/sdk pg
```

`@glokki-cms/sdk` is a peer dep (`^0.12.0`); `pg` is a peer dep of the Postgres
adapter (`^8.10.0`).

---

## 3. Migrate the cache table (run once)

```bash
npx glokki-cache-pg migrate --database-url "$POSTGRES_URL"
```

This creates one table and one GIN index (idempotent — `IF NOT EXISTS`):

```sql
CREATE TABLE IF NOT EXISTS cms_cache (
  key             text PRIMARY KEY,
  value           jsonb  NOT NULL,
  fetched_at      bigint NOT NULL,
  soft_expire_at  bigint NOT NULL,
  tags            text[] NOT NULL,
  etag            text
);
CREATE INDEX IF NOT EXISTS cms_cache_tags_gin ON cms_cache USING GIN (tags);
```

The GIN index on `tags` is what makes `invalidateByTag` (a `tags && $1` array
overlap) fast.

---

## 4. Wire up the client

### Pool vs Client — the one decision that matters

The adapter (`createPostgresStore`) takes a `pg.Pool` **or** a `pg.Client`. The
difference only affects **cross-process L1 fan-out** via Postgres
`LISTEN/NOTIFY`:

- A long-lived dedicated `Client` keeps a `LISTEN` open, so when one instance
  invalidates, others clear their in-memory L1 too.
- A `Pool` hands out a random connection per query, so `LISTEN` never receives
  notifications — no fan-out.

**On Vercel (serverless), fan-out is impossible regardless** — for two reasons:

1. Functions are ephemeral; nothing holds a `LISTEN` connection open between
   invocations.
2. Serverless Postgres providers (Vercel Postgres/Neon, Supabase) front the DB
   with PgBouncer in **transaction mode**, which does not support
   `LISTEN/NOTIFY` at all.

So the correct serverless setup is: use a **`Pool`** and **disable L1**
(`l1: false`). L2 (shared Postgres) is then the single source of truth, and a
publish webhook clears it for every instance at once — zero inconsistency
window.

```ts
// lib/cms.ts
import { Pool } from "pg"
import { createCachedClient } from "@glokki-cms/cache"
import { createPostgresStore } from "@glokki-cms/cache-postgres"

// Module scope → reused by warm Vercel instances. Use the POOLED connection
// string (Neon/Vercel Postgres "-pooler" endpoint) and a small max so cold
// starts don't exhaust the DB connection limit.
const pool = new Pool({ connectionString: process.env.POSTGRES_URL, max: 1 })

export const cms = createCachedClient({
  // ── SDK options ──
  baseUrl: process.env.CMS_DELIVERY_URL!, // e.g. https://app.yourcms.com/api/v1/delivery
  apiKey: process.env.CMS_API_KEY!,
  tenantSlug: process.env.CMS_TENANT_SLUG!, // REQUIRED — namespaces cache keys per tenant

  // ── cache options ──
  cache: {
    store: createPostgresStore({ client: pool }), // L2
    l1: false, // serverless: no in-memory L1 → no cross-instance staleness
    softTtl: 24 * 60 * 60_000, // 24h — see §6; freshness is enforced by the webhook, not TTL
    revalidateInBackground: true,
    // onCacheEvent: (e) => { if (e.type === "origin-error") console.warn(e) },
  },
})
```

From here, use `cms.pages.getBySlug(...)`, `cms.collections.listEntries(...)`,
etc. exactly like the plain SDK — caching is transparent. Preview mode
(`preview: true` + `previewToken`) automatically bypasses the cache for
`pages.getBySlug`.

> **Non-serverless deployments** (a single VM, or several long-lived pods where
> you want a fast shared-consistent L1): pass a dedicated `Client` instead of a
> `Pool` and keep `l1` on. Fan-out via `LISTEN/NOTIFY` then keeps every pod's L1
> consistent. The `channel` option (default `cms_cache_invalidate`) lets several
> apps share one database without crosstalk.

---

## 5. Receive invalidation webhooks (clear cache on publish)

You do **not** clear the cache manually after publishing — the CMS does it for
you via a signed webhook. The package ships the receiver.

```ts
// app/api/cms-invalidate/route.ts
import { createInvalidationHandler } from "@glokki-cms/cache"

import { cms } from "@/lib/cms"

const handler = createInvalidationHandler({
  secret: process.env.CMS_REVALIDATE_SECRET!, // == Revalidate Secret in the CMS panel
  cache: cms.cache,
})

export const POST = (req: Request) => handler(req)
```

The handler verifies the HMAC-SHA256 signature (`${timestamp}.${body}` with the
shared secret over the `X-Editor-Signature` / `X-Editor-Timestamp` headers),
rejects replays outside a 5-minute window, then calls `cms.cache.invalidate(tags)`
(clears L2 + L1) and broadcasts `publish()` for fan-out. It returns `204`.

The same URL also receives `revalidation` and `data-model-purge` payloads; the
handler discriminates by `payload.type` and politely returns `200 {ignored}` for
the ones it doesn't handle, so the CMS dispatcher won't retry. A `ping` payload
returns `200 {ok}`.

To react to those other payloads yourself (they are signature-verified by the
time you see them), pass `onUnhandled` — return a `Response` to answer the
webhook, or `undefined` to keep the default `200 {ignored}`:

```ts
const handler = createInvalidationHandler({
  secret: process.env.CMS_REVALIDATE_SECRET!,
  cache: cms.cache,
  // Slug-based revalidation payloads → purge the Next.js route cache too.
  onUnhandled: (payload) => {
    const slug = (payload as { slug?: unknown }).slug
    if (typeof slug !== "string") return undefined
    revalidatePath("/" + slug.replace(/^\/+/, ""))
    return Response.json({ revalidated: true })
  },
})
```

### Configure the CMS (tenant Visual Editor settings)

| Field                 | Value                                                                           |
| --------------------- | ------------------------------------------------------------------------------- |
| **Revalidate URL**    | `https://yourdomain.com/api/cms-invalidate`                                     |
| **Revalidate Secret** | a random secret (`openssl rand -hex 32`); same value as `CMS_REVALIDATE_SECRET` |

Without both set, the CMS does not dispatch invalidation webhooks (it no-ops).

---

## 6. How invalidation works on publish — and why TTL can be long

When a page's live visibility changes, the CMS dispatches a signed
`{ type: "invalidate", tags: [...] }` webhook:

```
Publish page in CMS
  └─ CMS collects the affected surrogate tags
       tags = [
         page-<pageId>,                ← the page itself
         delivery-pages:<tenant>,      ← page lists + getBySlug
         delivery-navigation:<tenant>, ← navigation
         layout:<key>:<locale>         ← if the page has a layout
       ]
       └─ POST → your Revalidate URL (signed)
            └─ createInvalidationHandler → cms.cache.invalidate(tags)
                 └─ DELETE FROM cms_cache WHERE tags && $1   (L2, shared)
                 └─ L1.invalidateByTag(tags)                 (local, if L1 on)
```

The deleted entries are gone, so the **next** request fetches fresh content from
the origin. This is immediate and independent of `softTtl`.

> Tenant-wide tags always use your tenant **slug** (`delivery-pages:<slug>`),
> the same identifier you pass as `tenantSlug` in the client config.

**Therefore `softTtl` does not gate freshness.** It only controls the
background-revalidation cadence for entries nobody invalidated. With
webhook-driven invalidation and rarely-changing content, a _short_ TTL just
generates pointless origin traffic. A _long_ TTL caches content essentially
until the next publish, with minimal load on the Delivery API.

`softTtl` is really your **safety net for a missed webhook** (network blip,
misconfig, secret mismatch): after it elapses, the entry is served stale once and
revalidated in the background, catching the change. Pick it as "how long I can
tolerate stale content _if a webhook is ever dropped_":

| Content changes…   | Suggested `softTtl`      |
| ------------------ | ------------------------ |
| a few times a week | `6 * 60 * 60_000` (6h)   |
| weekly or less     | `24 * 60 * 60_000` (24h) |
| effectively static | 24h–72h                  |

---

## 7. Manual invalidation (when you need it)

```ts
await cms.cache.invalidate([`page-${pageId}`]) // one page
await cms.cache.invalidate([`delivery-pages:${tenant}`]) // all page lists for a tenant
const { l1Size, l2Size } = await cms.cache.stats()
```

> `cms.cache.purge()` **throws** in v1 — there is no "clear everything". Invalidate
> by tag instead.

Available surrogate tags:

| Tag                                  | Clears                          |
| ------------------------------------ | ------------------------------- |
| `page-<pageId>`                      | a single page                   |
| `delivery-pages:<tenant>`            | page lists + every page by slug |
| `delivery-navigation:<tenant>`       | navigation                      |
| `delivery-redirects:<tenant>`        | redirects                       |
| `delivery-collections:<tenant>`      | collection lists                |
| `delivery-collection:<slug>`         | a collection + its entries      |
| `delivery-collection-entries:<slug>` | a collection's entries          |
| `layout:<key>:<locale>`              | pages embedding that layout     |
| `form:<formId>`                      | a form                          |

---

## 8. Garbage-collect expired entries

Invalidation removes active entries, but entries that are never read again just
age in place (this is intentional — it's what enables stale-on-error). Add a
periodic GC so the table doesn't grow unbounded. **Keep the GC window well above
`softTtl`** so it only removes genuinely abandoned entries (any visited entry has
its `fetched_at` refreshed on background revalidation):

```sql
-- Delete entries not refreshed in 30 days (30d >> 24h softTtl).
DELETE FROM cms_cache
WHERE fetched_at < (EXTRACT(EPOCH FROM now()) * 1000)::bigint - 30 * 86400000;
```

On Vercel, run it from a Cron route:

```ts
// app/api/cron/cache-gc/route.ts  (add to vercel.json crons, e.g. daily)
import { Pool } from "pg"

const pool = new Pool({ connectionString: process.env.POSTGRES_URL, max: 1 })

export async function GET(req: Request) {
  // Vercel Cron sends `Authorization: Bearer ${CRON_SECRET}`.
  if (
    req.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`
  ) {
    return new Response("Unauthorized", { status: 401 })
  }
  const cutoff = Date.now() - 30 * 86400000
  const { rowCount } = await pool.query(
    `DELETE FROM cms_cache WHERE fetched_at < $1`,
    [cutoff]
  )
  return Response.json({ deleted: rowCount })
}
```

```json
// vercel.json
{ "crons": [{ "path": "/api/cron/cache-gc", "schedule": "0 4 * * *" }] }
```

---

## 9. Environment variables

| Var                     | Used by               | Notes                                       |
| ----------------------- | --------------------- | ------------------------------------------- |
| `POSTGRES_URL`          | `lib/cms.ts`, GC cron | **pooled** connection string on Vercel      |
| `CMS_DELIVERY_URL`      | `lib/cms.ts`          | Delivery API base, e.g. `…/api/v1/delivery` |
| `CMS_API_KEY`           | `lib/cms.ts`          | tenant Delivery API key                     |
| `CMS_TENANT_SLUG`       | `lib/cms.ts`          | namespaces cache keys                       |
| `CMS_REVALIDATE_SECRET` | webhook route         | matches Revalidate Secret in CMS panel      |
| `CRON_SECRET`           | GC cron               | Vercel Cron bearer token                    |

---

## 10. Setup checklist

1. `bun add @glokki-cms/cache @glokki-cms/cache-postgres @glokki-cms/sdk pg`
2. `npx glokki-cache-pg migrate --database-url "$POSTGRES_URL"` (once)
3. Add `lib/cms.ts` (§4) and `app/api/cms-invalidate/route.ts` (§5).
4. Set env vars (§9) in Vercel.
5. In the CMS Visual Editor settings, set **Revalidate URL** →
   `https://yourdomain.com/api/cms-invalidate` and **Revalidate Secret** →
   the same value as `CMS_REVALIDATE_SECRET`.
6. Add the GC cron (§8).
7. Publish a page and confirm the next request serves the new content.

---

Source: https://glokki.com/docs/troubleshooting

# Troubleshooting Guide

Common issues when integrating glokki and how to fix them.

This guide covers the most frequent problems tenant developers encounter when setting up the SDK, configuring iframe embedding, and testing blocks in the visual editor. For initial setup, see the [Integration Guide](/docs/integration). For block-specific issues, see the [Block Development Guide](/docs/block-development).

---

## Table of Contents

1. [Iframe Blocked (CSP Misconfiguration)](#1-iframe-blocked-csp-misconfiguration)
2. [Handshake Failed (Origin Mismatch)](#2-handshake-failed-origin-mismatch)
3. [Editor Disconnected (Token Expired)](#3-editor-disconnected-token-expired)
4. ["Unknown Block" Placeholder](#4-unknown-block-placeholder)
5. [Iframe Not Loading (Network/CORS)](#5-iframe-not-loading-networkcors)
6. [Block Type Not Registered](#6-block-type-not-registered)
7. [Images Load From Storage Origin Instead of `/cdn/`](#7-images-load-from-storage-origin-instead-of-cdn)
8. [Debugging with DevTools](#8-debugging-with-devtools)

---

## 1. Iframe Blocked (CSP Misconfiguration)

### Symptom

The visual editor shows a blank iframe or a broken frame icon. The browser console displays:

```
Refused to frame 'https://your-app.com' because an ancestor violates the
following Content Security Policy directive: "frame-ancestors 'self'"
```

Or the older variant:

```
Refused to display 'https://your-app.com' in a frame because it set
'X-Frame-Options' to 'SAMEORIGIN'.
```

### Cause

The CMS loads your tenant app inside an iframe. By default, browsers block cross-origin framing. Your app must explicitly allow the CMS origin to embed it — but only when the page is loaded in editor mode (`?_editor=1`).

### Fix

Add middleware that sets `Content-Security-Policy: frame-ancestors` when the `_editor` query parameter is present. Here is a minimal version:

**Next.js middleware:**

```ts
// middleware.ts
import { type NextRequest, NextResponse } from "next/server"

const CMS_ORIGIN = new URL(process.env.CMS_BASE_URL || "http://localhost:3000")
  .origin

export function middleware(request: NextRequest) {
  const isEditorMode = request.nextUrl.searchParams.get("_editor") === "1"
  const response = NextResponse.next()

  if (isEditorMode) {
    // Allow the CMS to embed this page
    response.headers.set(
      "Content-Security-Policy",
      `frame-ancestors 'self' ${CMS_ORIGIN}`
    )
    // Remove X-Frame-Options — it takes precedence over CSP in older browsers
    response.headers.delete("X-Frame-Options")
  } else {
    // Block framing on public pages to prevent clickjacking
    response.headers.set("X-Frame-Options", "SAMEORIGIN")
  }

  return response
}
```

**If you already have a CSP:** The example above replaces the entire `Content-Security-Policy` header. If your app ships existing CSP directives (e.g., `script-src`, `img-src`), you must merge `frame-ancestors` into your existing policy rather than replacing it. Only add the `frame-ancestors` directive — do not drop your other directives on editor routes.

### Checklist

- `CMS_BASE_URL` matches the actual CMS origin (protocol + host + port)
- `X-Frame-Options` is deleted in editor mode (it overrides `frame-ancestors` in older browsers)
- The middleware runs on all routes that serve editor-embeddable pages
- If you already have a CSP, merge `frame-ancestors` into it rather than replacing the whole header
- If you use a reverse proxy (Nginx, Cloudflare), check that it is not adding its own `X-Frame-Options` header

---

## 2. Handshake Failed (Origin Mismatch)

### Symptom

The iframe loads your app, but the Component Library is empty and the page shows no blocks. No errors appear in the console. The editor appears "stuck" — the page renders but there is no editing UI.

### Cause

The postMessage bridge uses **strict origin matching** for security. Messages from origins that do not exactly match the expected origin are silently dropped — no errors are logged (this is by design to avoid leaking security information). Common mismatches:

| Expected                  | Actual                        | Problem                                                                                |
| ------------------------- | ----------------------------- | -------------------------------------------------------------------------------------- |
| `http://localhost:3001`   | `http://localhost:3000`       | Wrong port                                                                             |
| `https://app.example.com` | `http://app.example.com`      | HTTP vs HTTPS                                                                          |
| `https://app.example.com` | `https://www.app.example.com` | Subdomain mismatch                                                                     |
| `https://app.example.com` | `https://app.example.com:443` | Explicit port (browsers normalise this — usually not an issue, but some proxies don't) |

### Fix

1. **Check `frontendUrl` in CMS settings.** Go to **Dashboard → Settings → Visual Editor** and verify the **Frontend URL** matches your tenant app's origin exactly (protocol + host + port). For local development this is typically `http://localhost:3001`.

2. **Verify the CMS origin.** The `_editorOrigin` query parameter is set by the CMS when it builds the iframe URL (via `buildEditorIframeUrl()`). If the value is wrong, the issue is on the CMS side — check that you are accessing the CMS at its canonical URL (the origin the CMS derives from the incoming request headers). If you access the CMS at `http://127.0.0.1:3000` but the tenant app expects `http://localhost:3000`, the origins will not match.

3. **Verify both URLs in the browser address bar.** When the editor opens, the iframe URL will contain query parameters like:
   ```
   https://your-app.com/page?_editor=1&_editorOrigin=https%3A%2F%2Fcms.example.com&_editorToken=...
   ```
   Decode the `_editorOrigin` value and confirm it matches the CMS URL in your browser's main address bar.

### How to Debug Silent Origin Rejections

Since origin mismatches are silently dropped, use the browser DevTools to inspect `postMessage` traffic:

1. Open DevTools → Console
2. Add a temporary listener in the CMS window:
   ```js
   window.addEventListener("message", (e) =>
     console.log("msg from:", e.origin, e.data)
   )
   ```
3. Reload the editor. You should see `handshake:request` messages. Check the `e.origin` value — if it does not match what the CMS expects, the messages are being dropped.

---

## 3. Editor Disconnected (Token Expired)

### Symptom

The visual editor works initially, but after a long editing session the editor stops responding to changes. The CMS may show a "session expired" or "reconnect" prompt.

### Cause

Editor sessions use short-lived tokens (15-minute TTL) passed via the `_editorToken` query parameter. The CMS proactively sends `token:refresh` messages before the token expires, and the SDK updates the URL via `history.replaceState()` so that iframe reloads (including HMR) pick up the fresh token.

If the refresh mechanism fails — for example, if the postMessage bridge is broken (see [Origin Mismatch](#2-handshake-failed-origin-mismatch)) — the token will expire and the session will disconnect.

### Fix

1. **Ensure the handshake is working.** Token refresh relies on the same postMessage bridge as all other editor communication. If the handshake never completes, tokens cannot be refreshed. See [Handshake Failed](#2-handshake-failed-origin-mismatch).

2. **Don't strip query parameters.** If your app's routing strips or rewrites query parameters (`_editor`, `_editorOrigin`, `_editorToken`), the SDK cannot detect editor mode or receive token updates. Ensure these parameters pass through unchanged.

3. **Reload the editor.** If a session expires, close the editor tab and reopen the page in the visual editor. The CMS generates a fresh token on each editor open.

### Note

Under normal operation, token expiry should not happen — the CMS refreshes tokens automatically. If it happens repeatedly, it indicates a broken postMessage bridge rather than a token-specific problem.

---

## 4. "Unknown Block" Placeholder

### Symptom

Instead of your block's content, the editor shows a dashed-border placeholder with:

```
❓
your_block_type
Component not registered
```

### Cause

The block type in the page's component tree does not have a corresponding entry in the component registry. This happens when:

- The block was added in the CMS but the tenant app has not registered a component for that type
- The block type string has a typo (e.g., `"hero-banner"` vs `"heroBanner"`)
- The block was removed from the registry but pages still reference it
- The component `Map` used by `<CMSPageEditor>` is missing the entry (even if `createBlockRegistry()` has it)

### Fix

1. **Verify the block is in `createBlockRegistry()`.** Ensure your `defineBlock()` result is included in the array passed to `createBlockRegistry()`.

2. **Verify the component Map includes the block.** The `registry` Map passed to `<VisualEditorProvider>` must also contain the block type → component mapping:

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

3. **Check the type string.** Block types are case-sensitive. Ensure the `type` in `defineBlock()` matches what the CMS saved in the component tree. Use DevTools to inspect the `tree:update` message payload and compare `node.type` values.

4. **Duplicate type error.** If you see:
   ```
   createBlockRegistry: duplicate block type "hero". Each block type must be unique within a registry.
   ```
   Two `defineBlock()` calls use the same `type` value. Rename one of them.

---

## 5. Iframe Not Loading (Network/CORS)

### Symptom

The visual editor shows a loading spinner indefinitely, or the iframe area is completely blank. The browser console may show network errors.

### Cause

The CMS cannot reach your tenant app at the configured `frontendUrl`. This is a network-level problem, not a postMessage problem.

### Fix

1. **Verify your tenant app is running.** Open the `frontendUrl` directly in a new browser tab. If the page doesn't load, your app is down or unreachable.

2. **Check for port conflicts.** If both the CMS and tenant app run locally, ensure they use different ports (e.g., CMS on `:3000`, tenant app on `:3001`).

3. **Check firewall/VPN.** Corporate firewalls or VPNs can block localhost-to-localhost connections or cross-origin iframe loads.

4. **HTTPS in development.** If the CMS runs on HTTPS (even with a self-signed cert), the tenant app must also use HTTPS. Mixed content (HTTPS page embedding HTTP iframe) is blocked by browsers.

### Note on CORS

The visual editor uses `postMessage` for communication, not HTTP requests between origins. You do **not** need to configure CORS headers (`Access-Control-Allow-Origin`) for the editor to work. The only cross-origin header that matters is `Content-Security-Policy: frame-ancestors` (see [Iframe Blocked](#1-iframe-blocked-csp-misconfiguration)).

CORS headers are only relevant if your tenant app makes direct API calls to the CMS (e.g., fetching content from the Delivery API on the client side). Server-side API calls are not subject to CORS.

---

## 6. Block Type Not Registered

### Symptom

The Component Library in the CMS sidebar does not show your block. You can see other blocks, but your new one is missing.

### Cause

The block's definition was not included in the handshake. The Component Library is populated from block definitions sent during the `handshake:request` message — if a block is not in the registry at handshake time, it won't appear.

### Fix

1. **Add the block to `createBlockRegistry()`.** The block must be included in the array:

   ```ts
   import { YourNewBlock } from "./your-new-block"

   export const blockRegistry = createBlockRegistry([
     HeroBlock,
     YourNewBlock, // ← add here
   ])
   ```

2. **Pass `blockRegistry` to `<VisualEditorProvider>`.** The provider needs both the component Map (`registry`) and the typed block registry (`blockRegistry`) to send full block definitions in the v2 handshake:

   ```tsx
   <VisualEditorProvider
     registry={registry}
     blockRegistry={blockRegistry}
     fallbackTree={page.componentTree}
     pageSlug="/about"
   >
   ```

3. **Reload the editor.** The handshake happens once when the iframe loads. After changing the registry, you must reload the page in the visual editor (close and reopen, or use the browser refresh).

4. **Check for `defineBlock()` errors.** If the schema is not a `z.object()` at the root, `defineBlock()` may throw or produce invalid field descriptors. Check the browser console for errors.

### Missing Provider Error

If you see this error in the console:

```
useVisualEditor must be used within a <VisualEditorProvider>.
```

Your page component is not wrapped in the provider. Ensure `<VisualEditorProvider>` is an ancestor of `<CMSPageEditor>` and any component that calls `useVisualEditor()`.

---

## 7. Images Load From Storage Origin Instead of `/cdn/`

### Symptom

Published pages render `<img>` tags pointing at the S3/MinIO/storage host (e.g., `http://s3.example.com/bucket/tenants/<uuid>/foo.jpg`) instead of your tenant origin (`/cdn/foo.jpg`). The image may still load, but:

- The storage bucket name and tenant UUID prefix are exposed to visitors.
- Your CDN/edge cannot cache or invalidate the asset — every request hits storage directly.
- Responsive variants (`webpSrcset` / `avifSrcset`), focal-point-derived `objectPosition`, and asset dimensions are missing from the rendered HTML.

### Cause

The CMS rewrites every tenant-storage URL in a block's props to `/cdn/...` — whatever the prop is called and however deeply it is nested. When that doesn't happen, it is one of three things, in order of likelihood:

1. **The image is set in a per-viewport override.** Tablet/mobile overrides are not rewritten yet, so an image picked only there ships the absolute storage URL.
2. **The tenant app has no `/cdn/[...path]/route.ts`.** Without the proxy route, `/cdn/...` requests would 404, so the rewrite to relative paths would break the page.
3. **You're looking at the props panel right after picking an image.** The picker writes an absolute URL into the working tree; enrichment is read-time only. Save and reload the editor — the value collapses to `/cdn/...` on the next SSR pass.

### Fix

- For cause (1): set the image on the base (desktop) props and keep viewport overrides for non-image props. See [Query Parameters](/docs/asset-cdn-proxy#6-query-parameters) for what the rewritten URL carries.
- For cause (2): add the proxy route. See the full walkthrough in [Asset CDN Proxy](/docs/asset-cdn-proxy#3-quickstart).
- For cause (3): autosave + reload. No code change needed.

### Verification

After the fix, the published page's `<img src>` should be `/cdn/<filename>`, and a `curl -I` against that URL on your tenant origin should return `200 OK` with `Cache-Control` headers coming from the CMS.

---

## 8. Debugging with DevTools

### Inspect postMessage Traffic

The visual editor communicates via `window.postMessage`. All messages use the `__editorCms: true` namespace guard. To monitor traffic:

1. Open DevTools → Console in the **CMS window** (not the iframe)
2. Paste this snippet:
   ```js
   window.addEventListener("message", (e) => {
     if (e.data?.__editorCms) {
       console.log(`[editor] ${e.data.type}`, e.data.payload)
     }
   })
   ```
3. Reload the editor page. You should see the handshake sequence:
   ```
   [editor] handshake:request  { sdkVersion: "0.0.1", pageSlug: "/about", registeredBlockTypes: [...], registeredBlocks: [...] }
   [editor] ready              { timestamp: 1234567890 }
   ```
4. Interact with the editor — you'll see `block:clicked`, `block:props-update`, `tree:update`, etc.

### Verify Editor Mode Detection

The SDK detects editor mode from URL query parameters. Check that your iframe URL contains all three parameters:

| Parameter       | Example                   | Purpose                              |
| --------------- | ------------------------- | ------------------------------------ |
| `_editor`       | `1`                       | Signals editor mode is active        |
| `_editorOrigin` | `https://cms.example.com` | CMS origin for postMessage targeting |
| `_editorToken`  | `eyJ...`                  | Short-lived session token            |

If `_editor=1` is present but `_editorOrigin` is missing, the SDK will **not** enter editor mode (both are required).

### Check the Handshake Response

After `handshake:request`, the CMS should reply with `handshake:ack`. If you see the request but no ack:

- The CMS did not receive the message (origin mismatch — see [section 2](#2-handshake-failed-origin-mismatch))
- The CMS received it but the `frontendUrl` does not match the iframe's origin
- The CMS iframe handler has an error (check the CMS console)

### Common DevTools Filters

Filter console output to focus on editor messages:

- **Chrome:** Type `[editor]` or `__editorCms` in the Console filter box
- **Firefox:** Use the filter input above the console
- **Safari:** Use the filter field in the Console tab

---

## Quick Reference

| Problem                                  | Most Likely Cause        | First Step                                             |
| ---------------------------------------- | ------------------------ | ------------------------------------------------------ |
| Blank iframe                             | CSP blocking             | Check browser console for "Refused to frame"           |
| Editor loads but no blocks               | Origin mismatch          | Verify `frontendUrl` matches tenant app URL            |
| Editor stops responding                  | Token expiry             | Close and reopen the editor                            |
| "Unknown Block" placeholder              | Missing registry entry   | Add block to `createBlockRegistry()` and component Map |
| Block not in Component Library           | Not in handshake payload | Add to `createBlockRegistry()`, reload editor          |
| "useVisualEditor must be used within..." | Missing provider         | Wrap page in `<VisualEditorProvider>`                  |
| Iframe loads wrong page                  | Incorrect `frontendUrl`  | Update Frontend URL in CMS settings                    |

---

## Next Steps

- [Integration Guide](/docs/integration) — full setup from install to visual editing
- [Block Development Guide](/docs/block-development) — creating and testing custom blocks
- [Editor Preview Route](/docs/editor-preview-route) — the route the visual editor loads, including CSP setup
