---
title: "Integration Guide"
url: "https://glokki.com/docs/integration"
locale: "en"
updated: "2026-10-26T23:25:40.161Z"
---

# 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.
```
