Skip to content

Get started

Connect a website to CMS Orbit

Every step from an empty Next.js app to a website that renders CMS pages, previews drafts in the editor and refreshes the moment editors publish, with every file in full.

Tutorial28 min read

This tutorial builds a Next.js website on CMS Orbit with the SDK, @bifrost-orbit-cms/sdk. It shows every file you add, what each line does and how to check each step before moving on. By the end:

  • every page an editor publishes appears on the website at its address;
  • moved pages redirect, deleted pages answer "not found", and each page has its title, description, share image and structured data;
  • the header menu and footer come from the CMS;
  • editors see their drafts beside the editor as they type, and can click a block in the preview to select it;
  • publishing refreshes only what changed, within a second or two;
  • search engines get a sitemap and robots.txt from the CMS.

It takes about an hour. Using another framework or language? See Connect a website without Next.js. To understand the moving parts first, read How your website and CMS Orbit work together.

Before you start

You need:

  • Node.js 20 or later. Node 22 or later lets the type generator read .env.local by itself (step 7).
  • Next.js 16 or later and React 19 or later. The SDK's Next.js helpers use the App Router.
  • An account in the space with the Developer, Administrator or Owner role, so you can create keys, webhooks and settings.
  • At least one published page in the space, such as the home page at /, so there is something to render.
  • Two environments, such as Staging and Production. Most spaces have them already. Drafts are previewed against Staging; the public website reads Production.

What you will build

The SDK does not add files to your project. You add them, one step at a time, and only cms/types.ts is written for you, by the type generator. When you are done, the app has these new files:

Text
my-site/├── .env.local├── .env.example├── proxy.ts├── lib/│   └── cms.ts├── cms/│   ├── types.ts│   └── registry.tsx├── components/│   ├── page-body.tsx│   ├── live-page.tsx│   └── blocks/│       └── hero.tsx└── app/    ├── layout.tsx    ├── not-found.tsx    ├── [[...slug]]/page.tsx    ├── api/preview/route.ts    ├── api/preview/exit/route.ts    ├── api/revalidate/route.ts    ├── sitemap.xml/route.ts    └── robots.txt/route.ts
.env.local
What it does
Keys and addresses. Never committed.
Step
5
.env.example
What it does
The same names without values, committed for the next developer
Step
5
lib/cms.ts
What it does
The CMS client, shared by every server file
Step
6
cms/types.ts
What it does
Types for your components and content types. Generated: do not edit it.
Step
7
components/blocks/hero.tsx
What it does
One file like this for each CMS component
Step
8
cms/registry.tsx
What it does
Which React component draws each CMS component
Step
9
app/[[...slug]]/page.tsx
What it does
Every CMS page, at its own address
Step
10
app/not-found.tsx
What it does
The 404 page
Step
11
app/layout.tsx
What it does
Menu, footer and the preview bridge. Replaces the one Next.js created.
Step
12
proxy.ts
What it does
Sends the editor's ?preview= links to the preview route
Step
14
app/api/preview/route.ts
What it does
Checks a preview link and turns on draft mode
Step
14
app/api/preview/exit/route.ts
What it does
Turns draft mode off
Step
14
components/page-body.tsx
What it does
Draws the published page, or the live draft while previewing
Step
14
components/live-page.tsx
What it does
Shows editors' changes as they type
Step
14
app/api/revalidate/route.ts
What it does
Receives webhooks and refreshes the cache
Step
15
app/sitemap.xml/route.ts
What it does
The sitemap, from the CMS
Step
16
app/robots.txt/route.ts
What it does
robots.txt, from the CMS
Step
16

package.json also gains a cms:types script in step 7. Nothing else changes.

A website with several regions on one domain, such as /de and /ch, puts the page route under app/[region]/ instead. Regions and languages shows that layout; build this tutorial first, then move the files.

1Create the Next.js app

Skip this step if you already have a Next.js 16 app.

Shell
npx create-next-app@latest my-site --ts --app --eslint --no-src-dir --import-alias "@/*"cd my-site

--no-src-dir keeps the folders at the top level, as in the tree above. If you use src/, put lib, cms, components, app and proxy.ts inside src/ and change --out cms/types.ts to --out src/cms/types.ts in step 7. The @/ import alias then points at src/.

Check it: npm run dev starts the app on http://localhost:3000. Stop it again with Ctrl+C.

2Install the SDK

Shell
npm install @bifrost-orbit-cms/sdk server-only
  • @bifrost-orbit-cms/sdk is the client, the React and Next.js helpers and the type generator. It has no dependencies of its own; React and Next.js are peer dependencies.
  • server-only makes the build fail if a file meant for the server, such as lib/cms.ts, is ever imported into browser code. It keeps the preview key out of the browser.

The SDK is split into entry points, so a page only loads what it uses:

@bifrost-orbit-cms/sdk
Holds
createCmsClient, errors, cacheTags, image helpers, types
Runs in
Server and browser
@bifrost-orbit-cms/sdk/react
Holds
CmsPage, CmsLivePage, defineRegistry, CmsImage, RichText, campaign hooks
Runs in
Server and browser
@bifrost-orbit-cms/sdk/next
Holds
Preview, webhook, sitemap and robots handlers, CmsBridge, gone
Runs in
Server only
@bifrost-orbit-cms/sdk/seo
Holds
toMetadata, JsonLd, hreflang, sitemap XML
Runs in
Server and browser
@bifrost-orbit-cms/sdk/testing
Holds
createMockClient, fixtures
Runs in
Tests

3Find the API address and your region key

The API address is https://api.bifrostorbit.com/cms for every space. The SDK adds the rest of the path itself. Configuration › API playground shows the same address at the top.

The region key names the part of the space the website shows. Open Configuration › Environments, open the environment, and read the key of the region, such as germany. A space with one region still has one, often called global.

Important

Use the region's key, not its short code. The API accepts both for reading, but webhooks name the region by its key, and the page is only refreshed if the two match. See Caching and refreshing pages.

4Create the two keys

Open Configuration › API keys and select New API key twice.

The public key, for the live website:

  1. Name: Website.
  2. Environment: Production.
  3. Regions and Languages: leave as every region and every language, unless the website should only see some.
  4. Expires: Never. A key for a live website usually never expires.
  5. Read drafts: off. It cannot be turned on for Production.
  6. Select Create key, then copy the token, which starts with cms_pk_. It is shown once.

The preview key, for showing drafts:

  1. Name: Website preview.
  2. Environment: Staging, or the environment editors work in before content goes live.
  3. Read drafts: on.
  4. Select Create key and copy the token, which starts with cms_sk_.

The public key may only read published content, so it is safe in browser code. The preview key can read unpublished drafts: it stays on the server, and the SDK refuses to create a client with it in a browser. See Keys, permissions and sign-in.

Each plan includes a number of active API keys across the organisation, in every space: 3 on Starter, 20 on Growth and no limit on Enterprise. The API keys page shows how many are in use, and New key is turned off when the plan is full. Revoked and expired keys do not count, so revoke a key you no longer use to free a place.

A key belongs to one environment. A staging website uses a public key from Staging, and the live website one from Production. Deploy your website sets up each environment.

5Add the settings

Create .env.local in the root of the app:

.env.localShell
CMS_API_URL=https://api.bifrostorbit.com/cmsCMS_PUBLIC_KEY=cms_pk_...CMS_PREVIEW_KEY=cms_sk_...CMS_DEFAULT_REGION=germanyCMS_WEBHOOK_SECRET=

CMS_WEBHOOK_SECRET stays empty until step 15.

CMS_API_URL
Required
Yes
Read by
Your lib/cms.ts, the type generator, and CmsBridge when it is not given a client
Holds
The API address from step 3
CMS_PUBLIC_KEY
Required
Yes
Read by
Your lib/cms.ts and the type generator
Holds
The cms_pk_ key from step 4
CMS_PREVIEW_KEY
Required
For previews
Read by
Your lib/cms.ts
Holds
The cms_sk_ key from step 4. Without it, previews fail with "Previewing needs a previewKey on the client".
CMS_DEFAULT_REGION
Required
Recommended
Read by
Your lib/cms.ts
Holds
The region key from step 3
CMS_WEBHOOK_SECRET
Required
For cache refresh
Read by
Your webhook route
Holds
The whsec_ secret from step 15

Only CMS_API_URL and CMS_PUBLIC_KEY are read by the SDK itself. The others are names this tutorial uses in its own code; you may call them anything.

Next.js keeps .env.local out of git, and its .gitignore already lists .env*. So the next developer knows what to fill in, commit an example without values:

.env.exampleShell
CMS_API_URL=https://api.bifrostorbit.com/cmsCMS_PUBLIC_KEY=CMS_PREVIEW_KEY=CMS_DEFAULT_REGION=CMS_WEBHOOK_SECRET=

If .env.example is ignored by the .env* rule, add !.env.example to .gitignore.

Do not give these settings a NEXT_PUBLIC_ prefix. That would copy them into the browser bundle.

6Create the client

lib/cms.tsTypeScript
import 'server-only'import { createCmsClient } from '@bifrost-orbit-cms/sdk'export const cms = createCmsClient({  baseUrl: process.env.CMS_API_URL!,  apiKey: process.env.CMS_PUBLIC_KEY!,  previewKey: process.env.CMS_PREVIEW_KEY || undefined,  defaultRegion: process.env.CMS_DEFAULT_REGION,  cache: { revalidate: 60, tags: true },})

Every server file imports cms from here, so there is one client with one set of settings.

baseUrl
What it does
The API address. https://api.bifrostorbit.com/cms, …/cms/api/cdn and …/cms/api/cdn/v1 all work; the client works out the rest. Required.
apiKey
What it does
The public key, sent as Authorization: Bearer cms_pk_… on every published read. Required.
previewKey
What it does
The preview key, used instead of apiKey for calls with preview: true.
defaultRegion
What it does
The region a call reads when it does not name one. Without it, the API answers with the space's first region.
cache.revalidate
What it does
How many seconds Next.js keeps an answer before asking the CMS again. 60 means a page is at most a minute out of date even if a webhook is missed. false keeps answers until a webhook refreshes them.
cache.tags
What it does
Labels each answer with cache tags, such as cms:page:germany:/about, so the webhook can refresh exactly that page. Without it, webhooks cannot refresh anything.

The client also takes timeout, the time allowed for each attempt (5000 milliseconds by default), retries, the extra attempts after a network error, a timeout or a 5xx answer (2 by default, after 250 and 750 milliseconds), and fetch, to swap in your own fetch for tracing or tests. The SDK lists every option.

Preview calls are never cached: they send cache: 'no-store', whatever cache says.

Check it: this does not run anything yet. Step 7 is the first call to the CMS.

7Generate types

The type generator reads your space's content types and components and writes a TypeScript type for each. Add a script to package.json:

JSON
{  "scripts": {    "cms:types": "node --env-file-if-exists=.env.local node_modules/@bifrost-orbit-cms/sdk/bin/cms-orbit.mjs generate --out cms/types.ts"  }}

Then run it:

Shell
npm run cms:types

It prints something like Wrote 12 components and 4 content types to cms/types.ts.

--env-file-if-exists needs Node 22 or later; it loads .env.local so the generator finds CMS_API_URL and CMS_PUBLIC_KEY. On Node 20, run npx cms-orbit generate --out cms/types.ts --url https://api.bifrostorbit.com/cms --key cms_pk_... instead. In CI, where the settings are already in the environment, npx cms-orbit generate --out cms/types.ts is enough.

--out
Default
src/cms/types.ts
Means
Where to write the file. Folders are created.
--url
Default
CMS_API_URL
Means
The API address
--key
Default
CMS_PUBLIC_KEY
Means
Any delivery key for the environment
--include references
Default
off
Means
Type Reference fields as the entries they point at, for websites that read with include: ['references']

The file it writes looks like this, for a space with a hero component and a page content type:

cms/types.tsTypeScript
// Generated by `cms-orbit generate` from the CMS schema. Do not edit.import type { CmsImage, CmsLink, CmsNode, CmsRichText } from "@bifrost-orbit-cms/sdk";export interface HeroBlock {  type: "hero"  heading: string  /** The paragraph under the heading */  text?: CmsRichText  image?: CmsImage | null  link?: CmsLink | null}export type Block = HeroBlockexport interface PageFields {  summary?: string}declare module "@bifrost-orbit-cms/sdk" {  interface CmsBlockTypes {    hero: HeroBlock  }}
  • One …Block interface per component, with type set to its key, and one …Fields interface per content type.
  • A field is optional (?) unless it is required and always shown. A field that is only shown when another field has a certain value is optional, and its comment says when it is delivered.
  • Field descriptions from the CMS become comments, so your editor shows them on hover.
  • The declare module block tells the SDK which components exist. That is what makes BlockProps<'hero'> and defineRegistry know your fields.

Field types lists the TypeScript type of every field type.

Commit cms/types.ts. Run the script again whenever someone changes a component or content type, and run it in CI so a schema change that breaks the website fails the build instead of the live site. Test your website shows the CI step.

Check it: open cms/types.ts and find your components. If the command printed The CMS answered 401, the key is wrong or revoked. Set CMS_API_URL and CMS_PUBLIC_KEY means .env.local was not loaded.

8Write a component

Each component an editor can add to a page needs a React component that draws it. Open Configuration › Components to see your space's components, their keys and fields, or read them in cms/types.ts. This example draws the hero above:

components/blocks/hero.tsxTypeScript
import { CmsImage, RichText, type BlockProps } from '@bifrost-orbit-cms/sdk/react'export function Hero({ block }: BlockProps<'hero'>) {  return (    <section data-cms-id={block.id} className="hero">      <h1>{block.heading}</h1>      <RichText value={block.text} className="hero-text" />      <CmsImage image={block.image} sizes="(min-width: 1024px) 50vw, 100vw" priority />      {block.link?.href ? (        <a href={block.link.href} target={block.link.target ?? undefined}>          {block.link.label ?? 'Read more'}        </a>      ) : null}    </section>  )}
  • BlockProps<'hero'> types block as HeroBlock plus id and variant. A misspelt field is a type error.
  • data-cms-id={block.id} on the outer element lets editors click the block in the preview to select it. Without it, clicking selects the block around it.
  • RichText writes the field's HTML into a div, or the element you pass as as. It renders nothing for an empty field.
  • CmsImage renders an <img> with the image's alt text, width and height. With sizes, it adds a srcset of smaller copies so phones download less. priority loads it straight away; leave it off for images further down the page.
  • Links carry href, which is null when the link points at a page that is not live, so check it before rendering.

Render components covers everything else: nested components, slots, variants, shared sections, images with next/image, links, and components that need the browser.

9Register your components

The registry maps each component key to the React component that draws it:

cms/registry.tsxTypeScript
import '@/cms/types'import { defineRegistry } from '@bifrost-orbit-cms/sdk/react'import { Hero } from '@/components/blocks/hero'export const registry = defineRegistry({  hero: Hero,})
  • import '@/cms/types' loads the generated types, so defineRegistry knows every component. Keep it, even though nothing is imported by name.
  • Leaving a component out is a type error. When someone adds a component in the CMS and you regenerate the types, the build tells you to draw it.
  • A component that never appears on pages, such as one only used inside banners, still needs an entry. Point it at a component that renders nothing: campaignButton: () => null.

10Render pages

One route renders every page in the CMS, whatever its address:

app/[[...slug]]/page.tsxTypeScript
import type { Metadata } from 'next'import { draftMode } from 'next/headers'import { notFound, permanentRedirect, redirect } from 'next/navigation'import { cache } from 'react'import { gone } from '@bifrost-orbit-cms/sdk/next'import { JsonLd, toMetadata } from '@bifrost-orbit-cms/sdk/seo'import { PageBody } from '@/components/page-body'import { cms } from '@/lib/cms'type Props = { params: Promise<{ slug?: string[] }> }export const revalidate = 60export function generateStaticParams() {  return []}const load = cache(async (path: string, preview: boolean) => cms.resolve({ path, preview }))async function resolveFor({ params }: Props) {  const { slug = [] } = await params  const { isEnabled } = await draftMode()  return load(`/${slug.join('/')}`, isEnabled)}export async function generateMetadata(props: Props): Promise<Metadata> {  const result = await resolveFor(props)  return result.kind === 'page' ? toMetadata(result.page) : {}}export default async function Page(props: Props) {  const result = await resolveFor(props)  if (result.kind === 'redirect') {    if (result.permanent) permanentRedirect(result.to)    redirect(result.to)  }  if (result.kind === 'gone') gone()  if (result.kind === 'notFound') notFound()  return (    <>      <JsonLd page={result.page} />      <PageBody page={result.page} />    </>  )}

Line by line:

  • [[...slug]] matches every address, including /. /products/hiking-boots arrives as slug: ['products', 'hiking-boots'], and the home page as no slug at all. '/' + slug.join('/') turns it back into the address the CMS stores: starting with /, with no trailing slash.
  • revalidate = 60 keeps each rendered page for up to 60 seconds. Keep it the same as cache.revalidate in lib/cms.ts.
  • generateStaticParams returning [] builds no pages ahead of time, but lets Next.js keep each page after its first visit. Builds stay fast however many pages the space has, and they do not need the CMS to be reachable, though the settings must be present for lib/cms.ts.
  • cache from React makes generateMetadata and Page share one call to the CMS for each request instead of two.
  • draftMode() is on only after an editor opened a preview link (step 14). Then preview: true reads the draft with the preview key and skips every cache. For visitors it is off, and the page comes from the cache.
  • cms.resolve asks the CMS what lives at the address. It answers one of four kinds:
page
Means
A published page, in result.page
What the route does
Renders it
redirect
Means
The address moved, to result.to. result.permanent is true for 301 and 308, and result.status has the exact code.
What the route does
permanentRedirect (308) or redirect (307)
gone
Means
The page was deliberately removed
What the route does
gone(), which shows your 404 page
notFound
Means
Nothing is at this address
What the route does
notFound(), which shows your 404 page

Redirects come from the editors' redirect list, and from the CMS itself when an editor changes a page's address, so old links keep working without any code. Next.js can only answer 404 from notFound(), so gone() does the same; to answer a true 410, see "Gone pages" in How your website and CMS Orbit work together.

  • toMetadata turns the page's SEO fields into a Next.js Metadata object: title, description, canonical address, robots, Open Graph, Twitter card, search engine verification and hreflang links. Metadata, sitemaps and robots explains each one.
  • JsonLd writes the page's structured data as a <script type="application/ld+json">, such as breadcrumbs and organisation details.
  • PageBody draws the components. It comes in step 14; until then, use <CmsPage page={result.page} registry={registry} /> from @bifrost-orbit-cms/sdk/react with the registry from step 9.

If the CMS cannot be reached, resolve throws a CmsUnavailableError after its retries. A page already in the cache keeps being served until a new copy can be fetched, so visitors only see an error for a page nobody has opened yet. Deploy your website shows how to show a friendly page instead.

11Add a 404 page

app/not-found.tsxTypeScript
import Link from 'next/link'export default function NotFound() {  return (    <main>      <h1>Page not found</h1>      <p>The page you were looking for is not here.</p>      <Link href="/">Go to the home page</Link>    </main>  )}

notFound() and gone() both show this page with status 404. To make the text editable, read it from a site-wide content entry with cms.findGlobal, as the layout does in the next step.

The layout wraps every page, so it holds what every page shares:

app/layout.tsxTypeScript
import type { ReactNode } from 'react'import { CmsBridge } from '@bifrost-orbit-cms/sdk/next'import { cms } from '@/lib/cms'import './globals.css'export default async function RootLayout({ children }: { children: ReactNode }) {  const [menu, footer] = await Promise.all([    cms.findNavigation({ key: 'main' }).catch(() => null),    cms.findGlobal({ key: 'footer' }).catch(() => null),  ])  return (    <html lang="en">      <body>        <header data-cms-edit="navigation:main" data-cms-edit-label="Menu">          <nav>            {menu?.items.map((item) =>              item.href ? (                <a key={item.href} href={item.href} target={item.target ?? undefined}>                  {item.label}                </a>              ) : null,            )}          </nav>        </header>        {children}        <footer data-cms-edit="global:footer" data-cms-edit-label="Footer">          {typeof footer?.fields.text === 'string' ? <p>{footer.fields.text}</p> : null}        </footer>        <CmsBridge client={cms} />      </body>    </html>  )}
  • findNavigation({ key: 'main' }) reads a menu by the key editors gave it in Menus. Each item has label, href, target, icon, badge, description and children for sub-menus. Items that point at an unpublished page are left out.
  • findGlobal({ key: 'footer' }) reads a site-wide entry, such as a footer or contact details, by its key. Its fields are in fields, and its components in components.
  • find… answers null when the menu or entry does not exist, where get… would throw. .catch(() => null) also keeps the website up if the CMS cannot be reached; the cached menu is served meanwhile.
  • data-cms-edit marks the header and footer in the preview, so an editor hovering them sees "Menu" or "Footer" and can open what they are made from. List several, separated by spaces: global:<key>, navigation:<key> and banner:<id>.
  • CmsBridge adds the preview script, only while draft mode is on. Visitors never load it. Keep it last in <body>.

Menus, site-wide content and lists covers sub-menus, typed globals, index pages, filters, search and references.

13See your first page

Shell
npm run dev

Open http://localhost:3000. You should see the home page from the CMS, with the menu above and the footer below.

  • A dashed box saying "No renderer registered for …" means the page uses a component that is not in the registry. It is shown only in development; production renders nothing for it. Add the component, or regenerate the types if it is new.
  • The 404 page means nothing is published at / in the region you set. Open another address you know is published, or check CMS_DEFAULT_REGION.
  • An error mentioning 401 means the key is wrong, revoked or from another environment.

Troubleshooting your website lists every message and what to do.

14Preview drafts in the editor

Editors see the website beside the editor. The CMS opens your page with a short-lived preview token, such as https://staging.example.com/products/hiking-boots?preview=eyJ…. Four small files turn that into a draft view that changes as editors type.

The preview route checks the token with the CMS and turns on draft mode:

app/api/preview/route.tsTypeScript
import { createPreviewHandler } from '@bifrost-orbit-cms/sdk/next'import { cms } from '@/lib/cms'export const GET = createPreviewHandler({ client: cms })

It reads the token from ?token= or ?preview= and asks the CMS whether it is valid. A missing token answers 400, and an invalid or expired one 401 with "That preview link is invalid or has expired". Tokens last an hour; the editor makes a new one as needed. For a valid token, it turns on Next.js draft mode, which sets a cookie, and redirects to ?path= if given, otherwise to the page the token is for.

The exit route turns draft mode off again, for a "Leave preview" link:

app/api/preview/exit/route.tsTypeScript
import { createPreviewExitHandler } from '@bifrost-orbit-cms/sdk/next'export const GET = createPreviewExitHandler()

/api/preview/exit?path=/about turns draft mode off and goes to /about. Both routes only redirect to addresses on your own website.

The proxy sends the editor's ?preview= links to the preview route, so the token works on any page address:

proxy.tsTypeScript
import { NextResponse, type NextRequest } from 'next/server'export function proxy(request: NextRequest) {  const token = request.nextUrl.searchParams.get('preview')  if (!token) return NextResponse.next()  const target = new URL('/api/preview', request.url)  target.searchParams.set('preview', token)  target.searchParams.set('path', request.nextUrl.pathname)  return NextResponse.redirect(target)}export const config = {  matcher: [{ source: '/((?!api/|_next/).*)', has: [{ type: 'query', key: 'preview' }] }],}

proxy.ts sits next to app, not inside it. Next.js 16 calls it before every matching request; the matcher limits it to requests with a preview query, so other visitors are not slowed down. In Next.js 15 and earlier the same file is called middleware.ts and exports middleware.

The page body shows the live draft while previewing, and the cached page otherwise:

components/page-body.tsxTypeScript
import type { CmsPage as CmsPageData } from '@bifrost-orbit-cms/sdk'import { CmsPage } from '@bifrost-orbit-cms/sdk/react'import { registry } from '@/cms/registry'import { LivePage } from './live-page'export function PageBody({ page }: { page: CmsPageData }) {  if (page.draft) return <LivePage page={page} />  return <CmsPage page={page} registry={registry} />}
components/live-page.tsxTypeScript
'use client'import type { CmsPage as CmsPageData } from '@bifrost-orbit-cms/sdk'import { CmsLivePage } from '@bifrost-orbit-cms/sdk/react'import { registry } from '@/cms/registry'export function LivePage({ page }: { page: CmsPageData }) {  return <CmsLivePage page={page} registry={registry} />}

page.draft is true only for pages read with preview: true. CmsLivePage listens to the editor through the bridge and redraws the page with every change, without reloading. It needs the browser, so it lives in a file starting with 'use client'; that also sends your block components to the browser, but only for previews, since published pages use CmsPage on the server.

Tell the CMS where the website is. Open the space's Settings and set Preview URL to the address of the website that has the preview key, such as https://staging.example.com. The editor adds each page's address after it. Add Other preview URLs for more websites, such as a second front end, and Preview breakpoints for the screen widths editors can switch between.

Check it:

  1. Open a page in the editor. The preview shows your website.
  2. Change a heading without publishing. The preview changes as you type.
  3. Click a block in the preview. The editor selects it.

Note

Next.js only lets the draft mode cookie work inside the editor's frame when the website runs with next build and next start, or is deployed. With next dev the cookie is limited to the website's own tab, so use Open in a new tab above the preview, or Copy a link to share and open the link. Your website must also allow the CMS to show it in a frame; see Set up the live preview.

Set up the live preview has the details: what the bridge does, marking the header and footer, useLivePage for templates, and previewing without the SDK.

15Refresh the website when editors publish

Without this step, a published change appears within revalidate seconds. With it, it appears straight away, and only the pages that changed are rebuilt.

Add the route:

app/api/revalidate/route.tsTypeScript
import { createRevalidateHandler } from '@bifrost-orbit-cms/sdk/next'export async function POST(request: Request): Promise<Response> {  const secret = process.env.CMS_WEBHOOK_SECRET  if (!secret) return Response.json({ error: { code: 'not_configured' } }, { status: 503 })  return createRevalidateHandler({ secret })(request)}

createRevalidateHandler throws when it is created without a secret, so this route creates it on each request and answers 503 while the setting is missing, rather than failing the build. Once the secret is always set, export const POST = createRevalidateHandler({ secret: process.env.CMS_WEBHOOK_SECRET! }) does the same in one line.

Create the webhook:

  1. Deploy the website so the route has a public https address. To try it from your computer first, see "Try it on your computer" in Caching and refreshing pages.
  2. Open Configuration › Webhooks and select New webhook.
  3. Name: Website. Address: https://www.example.com/api/revalidate. Environment: Production.
  4. Under Events, keep Page published, Page unpublished and Menu changed, and also tick Page deleted and Release published.
  5. Select Create webhook, copy the secret that starts with whsec_, and set it as CMS_WEBHOOK_SECRET where the website runs. Restart or redeploy so it is read.

Check it: in the webhook's ⋯ menu, select Send a test. The CMS shows "Website answered 200", and the answer is {"revalidated":[]}: the test is accepted and refreshes nothing. Then publish a change to a page and reload it on the website. Deliveries shows the publish, and the answer lists the tags that were refreshed, such as ["cms:page:germany:/about","cms:list","cms:sitemap","cms:references"].

What happens in between, which tags each event refreshes, and how to refresh in other ways are in Caching and refreshing pages. Creating, testing and securing webhooks is in Refresh the website when content changes.

16Add the sitemap and robots.txt

The CMS builds both from what is published, with the full addresses from Configuration › Domains.

app/sitemap.xml/route.tsTypeScript
import { createSitemapRoute } from '@bifrost-orbit-cms/sdk/next'import { cms } from '@/lib/cms'export const GET = createSitemapRoute(cms)
app/robots.txt/route.tsTypeScript
import { createRobotsRoute } from '@bifrost-orbit-cms/sdk/next'import { cms } from '@/lib/cms'export const GET = createRobotsRoute(cms)
  • The sitemap lists every published page in your default region with an address, leaving out pages set to no-index or left out of the sitemap by editors. Each entry has its last change, alternate languages and images.
  • robots.txt allows everything and names the sitemap for an indexable environment, and refuses everything for one that is not, such as Staging. A staging website can never be indexed by mistake.
  • Both answer with Cache-Control: public, max-age=300, and their CMS answers are refreshed by the webhook when pages are published.
  • Delete app/robots.ts or app/sitemap.ts if your app has them; Next.js does not allow both.

Check it: open http://localhost:3000/sitemap.xml and http://localhost:3000/robots.txt.

A sitemap holds up to 50,000 addresses. Larger websites, several regions and sitemap indexes are in Metadata, sitemaps and robots.

17Check everything

Before going live, work through this list:

Pages render
How
Open a few addresses, including / and one two levels deep
Unknown addresses answer 404
How
Open /this-does-not-exist and check the status in the browser's network tab
Redirects work
How
Change a page's address in the CMS and publish, then open the old address
Metadata is complete
How
View the page source: <title>, <meta name="description">, <link rel="canonical"> and og: tags
The preview works
How
The checks in step 14, on the deployed preview website
Publishing refreshes
How
Publish a change and reload the page within a few seconds
Menus refresh
How
Change the menu, then reload any page
The build does not depend on the CMS
How
npm run build succeeds with the network off, because no page is built ahead of time and the layout catches errors. The settings must still be present.
Types are current
How
npm run cms:types leaves cms/types.ts unchanged

What happens when

A visitor opens a page for the first time. Next.js runs the page route. cms.resolve calls the delivery API with the public key, labelled with the page's cache tag. The CMS answers. Next.js renders the page, sends it, and keeps both the answer and the rendered page.

The next visitors open it. Next.js sends the kept page without calling the CMS. After revalidate seconds, the next visitor still gets the kept page straight away, while Next.js fetches a fresh copy in the background for the visitor after.

An editor publishes. The CMS sends a signed message to your webhook route, naming the page, its region and its address. The route checks the signature and refreshes that page's tag, plus lists, sitemaps and pages showing references. The next visitor to the page waits for a fresh copy from the CMS; everything else stays cached.

An editor previews. The editor loads your page with ?preview=. The proxy sends it to the preview route, which checks the token with the CMS, turns on draft mode and redirects back. The page route sees draft mode, reads the draft with the preview key, uncached, and renders it with CmsLivePage. The bridge script then sends each change as the editor types.

Draft

What you edit; saves by itself

Review

When the type needs approval

Published

A new version in the history

Delivery API

Cached up to 60 seconds

Website

Webhooks refresh it at once

What happens between an editor publishing and your website showing it.

Where to go next

Was this page helpful?

Read next