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.
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
npm install @glokki-cms/sdk @glokki-cms/react zod
Or with Bun:
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:
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@glokki-cms/react", "@glokki-cms/sdk"],
}
export default nextConfig
Environment Variables
Create a .env file:
# 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=productionon your production deployment.cmsMetadata()forcesrobots: noindex, nofollowon every page wheneverAPP_ENVis anything other thanproduction— 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.
3. Create the CMS Client
The SDK provides createClient() to connect to the Delivery API.
// 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
// 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:
// 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:
// 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:
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:
// 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>:
// 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
<VisualEditorProvider>reads?_editor=1,?_editorOrigin, and?_editorTokenfrom the URL- It initiates a postMessage handshake with the CMS, sending the full block registry (schemas, metadata, defaults)
- The CMS responds with the current component tree and editor state
<CMSPageEditor>renders the live page with editor overlays — block selection, hover highlights, drag handles, and gap inserters- 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 |
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 for the full walkthrough.
useVisualEditor() Hook
Access editor state from any component inside the provider:
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:
// 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: SAMEORIGINprevents 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.
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.
- Open the CMS Dashboard
- Go to Settings → Visual Editor
- Set Frontend URL to your app's URL (e.g.,
http://localhost:4000for local dev, orhttps://acme.comfor production) - 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.
// 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:
// 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.
// 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) |
| 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
- Go to Settings → Visual Editor in the CMS Dashboard
- Set Revalidation URL to
https://your-site.com/api/revalidate - Set a Revalidation Secret — copy this value to your app's
CMS_REVALIDATE_SECRETenv var
12. SEO Metadata
The React package provides cmsMetadata() to generate Next.js Metadata from CMS page data:
// 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
robotsdirectives
Warning —
APP_ENVcontrols indexing. UnlessAPP_ENVis exactlyproduction,cmsMetadata()returnsrobots: { index: false, follow: false }for every page, overriding the robots setting editors chose in the CMS. An unsetAPP_ENVcounts as non-production. SetAPP_ENV=productionon your production deployment, or your live site will be de-indexed. To decide per call instead, passenvexplicitly:cmsMetadata(page, { baseUrl, env: "production" }).
For JSON-LD structured data, use the <JsonLd> component:
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.
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
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
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:
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
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):
import { cmsMetadata } from "@glokki-cms/react"
const metadata = cmsMetadata(page, { baseUrl: "https://acme.com" })
// metadata.title, metadata.description, metadata.openGraph, etc.