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.localby 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:
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.env.examplelib/cms.tscms/types.tscomponents/blocks/hero.tsxcms/registry.tsxapp/[[...slug]]/page.tsxapp/not-found.tsxapp/layout.tsxproxy.ts?preview= links to the preview routeapp/api/preview/route.tsapp/api/preview/exit/route.tscomponents/page-body.tsxcomponents/live-page.tsxapp/api/revalidate/route.tsapp/sitemap.xml/route.tsapp/robots.txt/route.ts| File | What it does | Step |
|---|---|---|
.env.local | Keys and addresses. Never committed. | 5 |
.env.example | The same names without values, committed for the next developer | 5 |
lib/cms.ts | The CMS client, shared by every server file | 6 |
cms/types.ts | Types for your components and content types. Generated: do not edit it. | 7 |
components/blocks/hero.tsx | One file like this for each CMS component | 8 |
cms/registry.tsx | Which React component draws each CMS component | 9 |
app/[[...slug]]/page.tsx | Every CMS page, at its own address | 10 |
app/not-found.tsx | The 404 page | 11 |
app/layout.tsx | Menu, footer and the preview bridge. Replaces the one Next.js created. | 12 |
proxy.ts | Sends the editor's ?preview= links to the preview route | 14 |
app/api/preview/route.ts | Checks a preview link and turns on draft mode | 14 |
app/api/preview/exit/route.ts | Turns draft mode off | 14 |
components/page-body.tsx | Draws the published page, or the live draft while previewing | 14 |
components/live-page.tsx | Shows editors' changes as they type | 14 |
app/api/revalidate/route.ts | Receives webhooks and refreshes the cache | 15 |
app/sitemap.xml/route.ts | The sitemap, from the CMS | 16 |
app/robots.txt/route.ts | robots.txt, from the CMS | 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.
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
npm install @bifrost-orbit-cms/sdk server-only@bifrost-orbit-cms/sdkis 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-onlymakes the build fail if a file meant for the server, such aslib/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/sdkcreateCmsClient, errors, cacheTags, image helpers, types@bifrost-orbit-cms/sdk/reactCmsPage, CmsLivePage, defineRegistry, CmsImage, RichText, campaign hooks@bifrost-orbit-cms/sdk/nextCmsBridge, gone@bifrost-orbit-cms/sdk/seotoMetadata, JsonLd, hreflang, sitemap XML@bifrost-orbit-cms/sdk/testingcreateMockClient, fixtures| Import from | Holds | Runs in |
|---|---|---|
@bifrost-orbit-cms/sdk | createCmsClient, errors, cacheTags, image helpers, types | Server and browser |
@bifrost-orbit-cms/sdk/react | CmsPage, CmsLivePage, defineRegistry, CmsImage, RichText, campaign hooks | Server and browser |
@bifrost-orbit-cms/sdk/next | Preview, webhook, sitemap and robots handlers, CmsBridge, gone | Server only |
@bifrost-orbit-cms/sdk/seo | toMetadata, JsonLd, hreflang, sitemap XML | Server and browser |
@bifrost-orbit-cms/sdk/testing | createMockClient, fixtures | 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:
- Name:
Website. - Environment: Production.
- Regions and Languages: leave as every region and every language, unless the website should only see some.
- Expires: Never. A key for a live website usually never expires.
- Read drafts: off. It cannot be turned on for Production.
- Select Create key, then copy the token, which starts with
cms_pk_. It is shown once.
The preview key, for showing drafts:
- Name:
Website preview. - Environment: Staging, or the environment editors work in before content goes live.
- Read drafts: on.
- 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:
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_URLlib/cms.ts, the type generator, and CmsBridge when it is not given a clientCMS_PUBLIC_KEYlib/cms.ts and the type generatorcms_pk_ key from step 4CMS_PREVIEW_KEYlib/cms.tscms_sk_ key from step 4. Without it, previews fail with "Previewing needs a previewKey on the client".CMS_DEFAULT_REGIONlib/cms.tsCMS_WEBHOOK_SECRETwhsec_ secret from step 15| Setting | Required | Read by | Holds |
|---|---|---|---|
CMS_API_URL | Yes | Your lib/cms.ts, the type generator, and CmsBridge when it is not given a client | The API address from step 3 |
CMS_PUBLIC_KEY | Yes | Your lib/cms.ts and the type generator | The cms_pk_ key from step 4 |
CMS_PREVIEW_KEY | For previews | Your lib/cms.ts | The cms_sk_ key from step 4. Without it, previews fail with "Previewing needs a previewKey on the client". |
CMS_DEFAULT_REGION | Recommended | Your lib/cms.ts | The region key from step 3 |
CMS_WEBHOOK_SECRET | For cache refresh | Your webhook route | 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:
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
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.
baseUrlhttps://api.bifrostorbit.com/cms, …/cms/api/cdn and …/cms/api/cdn/v1 all work; the client works out the rest. Required.apiKeyAuthorization: Bearer cms_pk_… on every published read. Required.previewKeyapiKey for calls with preview: true.defaultRegioncache.revalidate60 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.tagscms:page:germany:/about, so the webhook can refresh exactly that page. Without it, webhooks cannot refresh anything.| Option | What it does |
|---|---|
baseUrl | 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 | The public key, sent as Authorization: Bearer cms_pk_… on every published read. Required. |
previewKey | The preview key, used instead of apiKey for calls with preview: true. |
defaultRegion | The region a call reads when it does not name one. Without it, the API answers with the space's first region. |
cache.revalidate | 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 | 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:
{ "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:
npm run cms:typesIt 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.
--outsrc/cms/types.ts--urlCMS_API_URL--keyCMS_PUBLIC_KEY--include referencesinclude: ['references']| Flag | Default | Means |
|---|---|---|
--out | src/cms/types.ts | Where to write the file. Folders are created. |
--url | CMS_API_URL | The API address |
--key | CMS_PUBLIC_KEY | Any delivery key for the environment |
--include references | off | 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:
// 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
…Blockinterface per component, withtypeset to its key, and one…Fieldsinterface 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 moduleblock tells the SDK which components exist. That is what makesBlockProps<'hero'>anddefineRegistryknow 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:
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'>typesblockasHeroBlockplusidandvariant. 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.RichTextwrites the field's HTML into adiv, or the element you pass asas. It renders nothing for an empty field.CmsImagerenders an<img>with the image's alt text, width and height. Withsizes, it adds asrcsetof smaller copies so phones download less.priorityloads it straight away; leave it off for images further down the page.- Links carry
href, which isnullwhen 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:
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, sodefineRegistryknows 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:
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-bootsarrives asslug: ['products', 'hiking-boots'], and the home page as noslugat all.'/' + slug.join('/')turns it back into the address the CMS stores: starting with/, with no trailing slash.revalidate = 60keeps each rendered page for up to 60 seconds. Keep it the same ascache.revalidateinlib/cms.ts.generateStaticParamsreturning[]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 forlib/cms.ts.cachefrom React makesgenerateMetadataandPageshare one call to the CMS for each request instead of two.draftMode()is on only after an editor opened a preview link (step 14). Thenpreview: truereads the draft with the preview key and skips every cache. For visitors it is off, and the page comes from the cache.cms.resolveasks the CMS what lives at the address. It answers one of four kinds:
pageresult.pageredirectresult.to. result.permanent is true for 301 and 308, and result.status has the exact code.permanentRedirect (308) or redirect (307)gonegone(), which shows your 404 pagenotFoundnotFound(), which shows your 404 pagekind | Means | What the route does |
|---|---|---|
page | A published page, in result.page | Renders it |
redirect | The address moved, to result.to. result.permanent is true for 301 and 308, and result.status has the exact code. | permanentRedirect (308) or redirect (307) |
gone | The page was deliberately removed | gone(), which shows your 404 page |
notFound | Nothing is at this address | 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.
toMetadataturns the page's SEO fields into a Next.jsMetadataobject: title, description, canonical address, robots, Open Graph, Twitter card, search engine verification and hreflang links. Metadata, sitemaps and robots explains each one.JsonLdwrites the page's structured data as a<script type="application/ld+json">, such as breadcrumbs and organisation details.PageBodydraws the components. It comes in step 14; until then, use<CmsPage page={result.page} registry={registry} />from@bifrost-orbit-cms/sdk/reactwith 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
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.
12Add the menu, footer and preview bridge
The layout wraps every page, so it holds what every page shares:
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 haslabel,href,target,icon,badge,descriptionandchildrenfor 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 infields, and its components incomponents.find…answersnullwhen the menu or entry does not exist, whereget…would throw..catch(() => null)also keeps the website up if the CMS cannot be reached; the cached menu is served meanwhile.data-cms-editmarks 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>andbanner:<id>.CmsBridgeadds 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
npm run devOpen 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 checkCMS_DEFAULT_REGION. - An error mentioning
401means 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:
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:
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:
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:
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} />}'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:
- Open a page in the editor. The preview shows your website.
- Change a heading without publishing. The preview changes as you type.
- 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:
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:
- Deploy the website so the route has a public
httpsaddress. To try it from your computer first, see "Try it on your computer" in Caching and refreshing pages. - Open Configuration › Webhooks and select New webhook.
- Name:
Website. Address:https://www.example.com/api/revalidate. Environment: Production. - Under Events, keep Page published, Page unpublished and Menu changed, and also tick Page deleted and Release published.
- Select Create webhook, copy the secret that starts with
whsec_, and set it asCMS_WEBHOOK_SECRETwhere 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.
import { createSitemapRoute } from '@bifrost-orbit-cms/sdk/next'import { cms } from '@/lib/cms'export const GET = createSitemapRoute(cms)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.tsorapp/sitemap.tsif 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:
/ and one two levels deep/this-does-not-exist and check the status in the browser's network tab<title>, <meta name="description">, <link rel="canonical"> and og: tagsnpm 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.npm run cms:types leaves cms/types.ts unchanged| Check | How |
|---|---|
| Pages render | Open a few addresses, including / and one two levels deep |
| Unknown addresses answer 404 | Open /this-does-not-exist and check the status in the browser's network tab |
| Redirects work | Change a page's address in the CMS and publish, then open the old address |
| Metadata is complete | View the page source: <title>, <meta name="description">, <link rel="canonical"> and og: tags |
| The preview works | The checks in step 14, on the deployed preview website |
| Publishing refreshes | Publish a change and reload the page within a few seconds |
| Menus refresh | Change the menu, then reload any page |
| The build does not depend on the CMS | 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 | 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
Where to go next
- Render components: nested components, slots, variants, images and links.
- Menus, site-wide content and lists: index pages, filters, search and references.
- Regions and languages: several regions, languages and fallbacks.
- Caching and refreshing pages: every cache, tag and refresh in detail.
- Test your website and Deploy your website.
- Render banners, popups and countdowns.
- The SDK: every method and option.
- Configuration › API playground: try any call against your space and see exactly what comes back.
Was this page helpful?