# Deploy your website

> Run a staging website for previews and a live website for visitors, with the right keys, settings and webhook for each, on Vercel, Netlify, Docker or a static host, and keep the website up if the CMS cannot be reached.

Source: https://docs.bifrostorbit.com/cms/developers/deploy-your-website/

This page takes a website built as in [Connect a website](https://docs.bifrostorbit.com/cms/developers/connect-a-website/) to production. Most websites run two copies of the same code: a **staging website** that editors preview drafts on, and the **live website** that visitors see.

## Two websites, two environments

| | Staging website | Live website |
| --- | --- | --- |
| Address | `https://staging.example.com` | `https://www.example.com` |
| Reads the environment | Staging | Production |
| `CMS_API_URL` | `https://api.bifrostorbit.com/cms` | `https://api.bifrostorbit.com/cms` |
| `CMS_PUBLIC_KEY` | A public key from Staging | A public key from Production |
| `CMS_PREVIEW_KEY` | A key from Staging with **Read drafts** on | Not set |
| `CMS_DEFAULT_REGION` | The region key | The region key |
| `CMS_WEBHOOK_SECRET` | From a webhook for Staging | From a webhook for Production |
| `SITE_INDEXABLE` | Not set | `true` |
| Used as | **Preview URL** in the space's settings | What visitors and search engines see |

Editors work in Staging, preview on the staging website, and promote to Production when ready. Promoting sends **Page published** to Production's webhook, so the live website refreshes.

A key from Production cannot read drafts, so the live website cannot show previews. That is deliberate: drafts never come near the public website.

A small website can skip staging: one website reading Production with a public key, and the CMS's own preview for editors. Editors then see their content, but not in your design.

## Before the first deploy

1. **Create the keys** for each environment in **Configuration › API keys**, as in step 4 of the tutorial.
2. **Add the settings** above to each deployment in your hosting's dashboard. Never commit them, and never give them a `NEXT_PUBLIC_` prefix.
3. **Add your domains** in **Configuration › Domains** for each environment and make the live one primary, so canonical links, hreflang and sitemaps use the right addresses. See [Add a website address](https://docs.bifrostorbit.com/cms/developers/domains/).
4. **Deploy.**
5. **Create a webhook** for each website, pointing at its `/api/revalidate`, with **Environment** set to the one it reads. Put each secret in that website's `CMS_WEBHOOK_SECRET` and deploy again so it is read.
6. **Set the Preview URL** in the space's **Settings** to the staging website's address.
7. **Check it** with the list at the end of this page.

## Let the editor show your website

The editor shows your staging website in a frame on `https://app.bifrostorbit.com`. Two things must allow it.

**Framing.** If your website sends `X-Frame-Options` or a `Content-Security-Policy` with `frame-ancestors`, allow the CMS on the staging website:

```ts
// next.config.ts
const nextConfig = {
  async headers() {
    if (process.env.SITE_INDEXABLE === 'true') return [{ source: '/:path*', headers: [{ key: 'X-Frame-Options', value: 'DENY' }] }]
    return [{ source: '/:path*', headers: [{ key: 'Content-Security-Policy', value: "frame-ancestors 'self' https://app.bifrostorbit.com" }] }]
  },
}
export default nextConfig
```

Do not send `X-Frame-Options` on the staging website at all: browsers apply it even when `frame-ancestors` allows the CMS.

**The bridge script.** While previewing, the page loads `https://api.bifrostorbit.com/cms/bridge.js`. If you set a `Content-Security-Policy` with `script-src`, add `https://api.bifrostorbit.com` to it on the staging website.

**Cookies.** Draft mode is a cookie, which Next.js sends with `SameSite=None; Secure` in production so it works inside the editor's frame. The staging website must therefore be served over `https`. Browsers that block cookies inside frames from other websites, such as Safari by default, keep draft mode off inside the editor; editors there can use **Open in a new tab** above the preview.

## Vercel

1. Import the repository. Vercel detects Next.js.
2. In **Settings › Environment Variables**, add the live settings for **Production**, and the staging settings for **Preview**, or for a separate staging project.
3. Deploy. Caches are shared across Vercel's servers, and `revalidateTag` from the webhook clears them everywhere.

Vercel's preview deployments for pull requests get their own addresses. Add them to the space's **Other preview URLs** if editors should see them, or keep one fixed staging address.

## Netlify

Netlify runs Next.js with its own runtime. Check its documentation for your Next.js version: this setup needs draft mode, `proxy.ts` and `revalidateTag`. Add the settings under **Site configuration › Environment variables**, with different values per deploy context if one site serves both staging and live.

## Docker and your own servers

Build a standalone server:

```ts
// next.config.ts
const nextConfig = { output: 'standalone' }
export default nextConfig
```

```dockerfile
# Dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
```

```bash
docker build -t my-site .
docker run -p 3000:3000 --env-file .env.production my-site
```

- **The build needs the settings too.** No CMS pages are built ahead of time, because `generateStaticParams` returns none, but Next.js renders its own 404 page with your layout while building, and `lib/cms.ts` throws without `CMS_API_URL` and `CMS_PUBLIC_KEY`. Pass them as build arguments or secrets. The build still succeeds if the CMS cannot be reached, because the layout catches errors.
- **Pages read the settings when they run**, so the running container's settings decide which environment visitors see.
- **The cache lives in the container**, under `.next/cache`. It starts empty after each deploy, so the first visit to each page fetches from the CMS.
- **Several containers** each keep their own cache, and a webhook only reaches one of them. Either run one container, or configure a shared cache with Next.js's `cacheHandler` option, backed by a store such as Redis, so a refresh reaches every copy.
- **Behind a proxy or CDN**, pass the `Host` header through, and do not cache HTML for longer than your `revalidate` time, or purge it from the webhook; see [Caching and refreshing pages](https://docs.bifrostorbit.com/cms/developers/caching-and-refresh/).

## Static hosting

With `output: 'export'`, Next.js writes plain HTML at build time. There is no server, so there is no draft mode, no preview route, no proxy and no webhook route. To use the CMS with a static website:

- **Build every page** in `generateStaticParams`, reading addresses from `cms.getSitemap()` or `cms.list`.
- **Rebuild on publish:** create a webhook whose address is your host's build hook, such as a Vercel deploy hook or a Netlify build hook. The host ignores the body and starts a build. Builds take minutes rather than seconds.
- **Preview** with the CMS's own preview, or run a small staging server with the full setup.

Most websites are better off with a server, even a small one.

## When the CMS cannot be reached

The SDK retries each call twice, after 250 and 750 milliseconds, then throws `CmsUnavailableError`. What visitors see:

- **Pages already in the cache** keep being served. Next.js only replaces a cached page once a fresh copy renders successfully.
- **Pages nobody has opened since the last deploy** fail to render, and show your error page.

Add a friendly error page:

```tsx
// app/error.tsx
'use client'

export default function Error({ reset }: { reset: () => void }) {
  return (
    <main>
      <h1>Something went wrong</h1>
      <p>Please try again in a moment.</p>
      <button onClick={reset}>Try again</button>
    </main>
  )
}
```

Or handle it in the page route and render a message with the right status:

```tsx
import { CmsUnavailableError } from '@bifrost-orbit-cms/sdk'

const result = await load(path, preview).catch((error) => {
  if (error instanceof CmsUnavailableError) return null
  throw error
})
if (!result) return <p>This page is temporarily unavailable.</p>
```

A page rendered this way is cached like any other, so keep `revalidate` short or the message stays for the full time.

For layouts, read menus and globals with `.catch(() => null)` so a missing menu does not take the whole website down.

To go further, keep the last good answer for each address on disk or in a store, and serve it when the CMS fails. The CMS's own answers allow shared caches to serve a stale copy for a day if the API fails, so a CDN in front of your website gives the same protection.

## Rate limits

Each key and each IP address may make 15,000 requests a minute. A website that caches as described stays far below this: most page views make no request at all. Going over answers `429`, which the SDK throws as `CmsRateLimitError` with `retryAfter` in seconds. It does not retry these itself.

Watch for loops that read every page at build time or in a script. Read at most 100 entries per request with `list`, and wait `retryAfter` seconds if you get a `429`.

## Rotating keys and secrets

- **A key:** create a new key, update the setting and deploy, check the website, then revoke the old key in **Configuration › API keys**.
- **The webhook secret:** webhooks cannot be edited; see "Change the secret" in [Refresh the website when content changes](https://docs.bifrostorbit.com/cms/developers/webhooks/).
- **After someone leaves**, or if a key may have leaked, rotate at once. A revoked key stops working straight away.

## Go-live checklist

| Check | How |
| --- | --- |
| Pages render on the live address | Open `/` and a deep page |
| Unknown addresses answer 404 | `curl -sI https://www.example.com/nothing-here` |
| robots.txt allows indexing | Open `/robots.txt` on the live website; it must not say `Disallow: /` |
| robots.txt refuses indexing on staging | Open `/robots.txt` on the staging website |
| The sitemap has live addresses | Open `/sitemap.xml`; addresses use the primary domain |
| Canonical links use the live domain | View the source of a page |
| The live website has no preview key | Check its settings |
| Publishing refreshes the live website | Promote or publish a change in Production and reload |
| Webhooks answer 200 | **Send a test** for each webhook |
| The editor shows the staging website | Open a page in the editor |
| Errors look right | Stop the network to the CMS on a test deployment and open a new page |
