glokki
View .md

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.


1. Prerequisites

  • You have already followed the Integration Guide 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. 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 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:

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

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:

// 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, 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:

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

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

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.

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.

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