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

Source: https://docs.bifrostorbit.com/cms/developers/connect-a-website/

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](https://docs.bifrostorbit.com/cms/developers/connect-any-website/). To understand the moving parts first, read [How your website and CMS Orbit work together](https://docs.bifrostorbit.com/cms/developers/how-it-works/).

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

| 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](https://docs.bifrostorbit.com/cms/developers/regions-and-languages/) shows that layout; build this tutorial first, then move the files.

## 1. Create the Next.js app

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

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

## 2. Install the SDK

```bash
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:

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

## 3. Find 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](https://docs.bifrostorbit.com/cms/developers/caching-and-refresh/).

## 4. Create 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](https://docs.bifrostorbit.com/cms/developers/security-and-access/).

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](https://docs.bifrostorbit.com/cms/developers/deploy-your-website/) sets up each environment.

## 5. Add the settings

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

```bash
# .env.local
CMS_API_URL=https://api.bifrostorbit.com/cms
CMS_PUBLIC_KEY=cms_pk_...
CMS_PREVIEW_KEY=cms_sk_...
CMS_DEFAULT_REGION=germany
CMS_WEBHOOK_SECRET=
```

`CMS_WEBHOOK_SECRET` stays empty until 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:

```bash
# .env.example
CMS_API_URL=https://api.bifrostorbit.com/cms
CMS_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.

## 6. Create the client

```ts
// lib/cms.ts
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.

| 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](https://docs.bifrostorbit.com/cms/developers/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.

## 7. Generate 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:

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

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

```ts
// cms/types.ts
// 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 = HeroBlock

export 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](https://docs.bifrostorbit.com/cms/developers/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](https://docs.bifrostorbit.com/cms/developers/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.

## 8. Write 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:

```tsx
// components/blocks/hero.tsx
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](https://docs.bifrostorbit.com/cms/developers/render-blocks/) covers everything else: nested components, slots, variants, shared sections, images with `next/image`, links, and components that need the browser.

## 9. Register your components

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

```tsx
// cms/registry.tsx
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`.

## 10. Render pages

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

```tsx
// app/[[...slug]]/page.tsx
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 = 60

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

| `kind` | 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](https://docs.bifrostorbit.com/cms/developers/how-it-works/).

- **`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](https://docs.bifrostorbit.com/cms/developers/seo-with-the-sdk/) 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](https://docs.bifrostorbit.com/cms/developers/deploy-your-website/) shows how to show a friendly page instead.

## 11. Add a 404 page

```tsx
// app/not-found.tsx
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.

## 12. Add the menu, footer and preview bridge

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

```tsx
// app/layout.tsx
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](https://docs.bifrostorbit.com/cms/developers/menus-globals-and-lists/) covers sub-menus, typed globals, index pages, filters, search and references.

## 13. See your first page

```bash
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](https://docs.bifrostorbit.com/cms/developers/troubleshooting/) lists every message and what to do.

## 14. Preview 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:

```ts
// app/api/preview/route.ts
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:

```ts
// app/api/preview/exit/route.ts
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:

```ts
// proxy.ts
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:

```tsx
// components/page-body.tsx
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} />
}
```

```tsx
// components/live-page.tsx
'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](https://docs.bifrostorbit.com/cms/developers/preview/).

[Set up the live preview](https://docs.bifrostorbit.com/cms/developers/preview/) has the details: what the bridge does, marking the header and footer, `useLivePage` for templates, and previewing without the SDK.

## 15. Refresh 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:**

```ts
// app/api/revalidate/route.ts
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](https://docs.bifrostorbit.com/cms/developers/caching-and-refresh/).
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](https://docs.bifrostorbit.com/cms/developers/caching-and-refresh/). Creating, testing and securing webhooks is in [Refresh the website when content changes](https://docs.bifrostorbit.com/cms/developers/webhooks/).

## 16. Add the sitemap and robots.txt

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

```ts
// app/sitemap.xml/route.ts
import { createSitemapRoute } from '@bifrost-orbit-cms/sdk/next'
import { cms } from '@/lib/cms'

export const GET = createSitemapRoute(cms)
```

```ts
// app/robots.txt/route.ts
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](https://docs.bifrostorbit.com/cms/developers/seo-with-the-sdk/).

## 17. Check everything

Before going live, work through this list:

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

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

## Where to go next

- [Render components](https://docs.bifrostorbit.com/cms/developers/render-blocks/): nested components, slots, variants, images and links.
- [Menus, site-wide content and lists](https://docs.bifrostorbit.com/cms/developers/menus-globals-and-lists/): index pages, filters, search and references.
- [Regions and languages](https://docs.bifrostorbit.com/cms/developers/regions-and-languages/): several regions, languages and fallbacks.
- [Caching and refreshing pages](https://docs.bifrostorbit.com/cms/developers/caching-and-refresh/): every cache, tag and refresh in detail.
- [Test your website](https://docs.bifrostorbit.com/cms/developers/test-your-website/) and [Deploy your website](https://docs.bifrostorbit.com/cms/developers/deploy-your-website/).
- [Render banners, popups and countdowns](https://docs.bifrostorbit.com/cms/developers/render-campaigns/).
- [The SDK](https://docs.bifrostorbit.com/cms/developers/sdk/): every method and option.
- **Configuration › API playground**: try any call against your space and see exactly what comes back.
