# Caching and refreshing pages

> Every cache between the CMS and a visitor, the settings that control them, which cache tags each call and each webhook uses, how to test refreshes on your computer, and what to check when a page stays out of date.

Source: https://docs.bifrostorbit.com/cms/developers/caching-and-refresh/

A website built on CMS Orbit should be fast for visitors and show new content as soon as editors publish. Both come from caching: pages are kept and served without asking the CMS, and a webhook tells the website exactly which ones to throw away. This page explains every part of it. The setup itself is steps 6, 10 and 15 of [Connect a website](https://docs.bifrostorbit.com/cms/developers/connect-a-website/).

## The caches

From the visitor back to the CMS:

| Cache | Kept where | Controlled by | Cleared by |
| --- | --- | --- | --- |
| A CDN in front of your website, if you have one | Your CDN | Your hosting's `Cache-Control` | Your CDN's purge, or expiry |
| **Rendered pages** (Next.js full route cache) | Your website's server | `export const revalidate` in the page | `revalidateTag`, or expiry |
| **CMS answers** (Next.js data cache) | Your website's server | `cache` on the client | `revalidateTag`, or expiry |
| One request's answers (React `cache`) | Memory, for one request | `cache()` around your call | The end of the request |
| The API's own answers | CMS Orbit | The CMS | The CMS, when content is published |

The two that matter are the middle two, and the SDK manages both: it labels every CMS answer with cache tags, and its webhook handler expires the right tags. Next.js then also throws away every rendered page that used an expired answer.

## The settings

```ts
// lib/cms.ts
export const cms = createCmsClient({
  // …
  cache: { revalidate: 60, tags: true },
})
```

```tsx
// app/[[...slug]]/page.tsx
export const revalidate = 60
```

| Setting | Means |
| --- | --- |
| `cache.revalidate: 60` | Each CMS answer is kept for 60 seconds. After that, the next request still gets it straight away while a fresh copy is fetched in the background. |
| `cache.revalidate: false` | Answers are kept until a webhook or a deploy clears them. Fastest, but a missed webhook leaves a page out of date until the next deploy. |
| `cache.revalidate` left out | Next.js's default for `fetch` applies. In Next.js 16 that means not cached, so every request calls the CMS. |
| `cache.tags: true` | Each answer is labelled with tags, so webhooks can expire it. Without it, webhooks have nothing to refresh. |
| No `cache` at all | Nothing is cached by the SDK. Every page view calls the CMS. |
| `export const revalidate = 60` | Each rendered page is kept for 60 seconds, then rebuilt in the background. Keep it the same as `cache.revalidate`. |

Previews are never cached, whatever the settings: calls with `preview: true` send `cache: 'no-store'`. GraphQL calls are never cached either.

**What to choose:** `revalidate: 60` with `tags: true` and a webhook. Publishing shows at once, and if a webhook is ever missed, the page catches up within a minute. Use a longer time, such as `300` or `3600`, for a busy website where a few minutes of delay after a missed webhook is acceptable; the webhook still makes publishing immediate.

## Cache tags

Every call labels its answer with `cms`, which every answer has, plus the tags in this table:

| Call | Tags |
| --- | --- |
| `getPage`, `findPage`, `resolve` | `cms:page:<region>:<path>`, such as `cms:page:germany:/about` |
| `getNavigation`, `findNavigation` | `cms:nav:<key>`, such as `cms:nav:main` |
| `getGlobal`, `findGlobal` | `cms:global` and `cms:global:<key>` |
| `list`, `getProducts`, `getTaxonomy` | `cms:list` |
| `getRegions`, `getLocales` | `cms:regions` |
| `getSitemapIndex`, `getSitemap`, `getRobots` | `cms:sitemap` |
| `getPopups` | `cms:popups` |
| `getBanners` | `cms:banners` |
| Any call with `include: ['references']` | also `cms:references` |
| `getSchema`, `graphql` | none; `getSchema` uses only `cms` and GraphQL is not cached |

`<region>` is the `region` the call passed, or the client's `defaultRegion`, or `default` if neither is set. `<path>` is the address exactly as you passed it. The language is not part of the tag, so every language of a page in a region shares one tag.

`cacheTags` from `@bifrost-orbit-cms/sdk` builds the same tags in your own code:

```ts
import { cacheTags } from '@bifrost-orbit-cms/sdk'

cacheTags.all                         // 'cms'
cacheTags.page('germany', '/about')   // 'cms:page:germany:/about'
cacheTags.navigation('main')          // 'cms:nav:main'
cacheTags.globals                     // 'cms:global'
cacheTags.global('footer')            // 'cms:global:footer'
cacheTags.lists                       // 'cms:list'
cacheTags.regions                     // 'cms:regions'
cacheTags.sitemap                     // 'cms:sitemap'
cacheTags.popups                      // 'cms:popups'
cacheTags.banners                     // 'cms:banners'
cacheTags.references                  // 'cms:references'
```

## What each change refreshes

The webhook handler turns each message into tags with `tagsForEvent(event, data)`, which you can also import from `@bifrost-orbit-cms/sdk/next`:

| Change | Event | Tags refreshed |
| --- | --- | --- |
| A page is published, unpublished, deleted, rolled back or promoted | `content.published`, `content.unpublished`, `content.deleted` | The page's tag, `cms:list`, `cms:sitemap`, `cms:references` |
| The same, for a page whose message has no region | The same | `cms`, which is everything |
| A page is published at a new address | `content.published` with `previousUrl` | `cms`, because menus and links elsewhere now point at the new address |
| An entry without an address is published, such as a footer, an FAQ entry, a banner or a popup | `content.*` with no `url` | `cms:global`, `cms:list`, `cms:popups`, `cms:banners`, `cms:references` |
| A shared section is published | `content.*` with `contentType: "sharedSection"` | `cms`, because any page may show it |
| A menu is created, changed or deleted | `navigation.changed` | `cms:nav:<key>` |
| A release runs | `release.published` | `cms:list`, `cms:sitemap`, `cms:references`. Each page in the release also sends its own `content.published`. |
| The test button | `webhook.test` | nothing |
| Any other event | | `cms` |

Expiring a tag also expires every rendered page that used an answer with that tag. Changing the `main` menu therefore rebuilds every page whose layout shows it, on its next visit.

## Inside the webhook handler

`createRevalidateHandler({ secret })` returns a `POST` route handler. For each request it:

1. Reads `x-orbit-timestamp` and `x-orbit-signature`, and the raw body as text.
2. Computes HMAC-SHA256 of the timestamp, a full stop and the body, keyed with the secret, and compares it with the signature in constant time.
3. Refuses a message more than five minutes away from your server's clock.
4. Parses the body and runs `tagsForEvent(event, data)`.
5. Calls `revalidateTag(tag, { expire: 0 })` for each tag, so the next visitor gets fresh content rather than one more stale copy.
6. Answers with the tags it refreshed.

| Answer | Body | When |
| --- | --- | --- |
| `200` | `{"revalidated":["cms:page:germany:/about","cms:list","cms:sitemap","cms:references"]}` | The message was genuine; the list may be empty, as for a test |
| `401` | `{"error":{"code":"unauthorized","message":"Bad signature"}}` | A header is missing, or the signature does not match the secret |
| `401` | `{"error":{"code":"expired","message":"The message is too old"}}` | The timestamp is more than five minutes from your clock |
| `500` | Next.js's error page | The body was not JSON. The CMS never sends this. |

Creating the handler without a secret throws straight away. To keep a build working before the secret is set, create it inside the route, as [Connect a website](https://docs.bifrostorbit.com/cms/developers/connect-a-website/) does in step 15.

### Choose how expired pages are rebuilt

By default an expired page is rebuilt while the next visitor waits, so they see the new content. To serve the old copy one last time and rebuild in the background instead, pass your own `revalidate`:

```ts
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { createRevalidateHandler } from '@bifrost-orbit-cms/sdk/next'

export const POST = createRevalidateHandler({
  secret: process.env.CMS_WEBHOOK_SECRET!,
  revalidate: (tag) => revalidateTag(tag, 'max'),
})
```

The same option lets you do more on each refresh, such as purging your CDN:

```ts
revalidate: (tag) => {
  revalidateTag(tag, { expire: 0 })
  if (tag === 'cms' || tag.startsWith('cms:page:')) void purgeCdn(tag)
},
```

### Refresh more than the SDK does

To change which tags an event refreshes, write the route yourself and reuse `tagsForEvent`. Verify the signature first, as in [Refresh the website when content changes](https://docs.bifrostorbit.com/cms/developers/webhooks/), then:

```ts
import { tagsForEvent } from '@bifrost-orbit-cms/sdk/next'

const tags = tagsForEvent(message.event, message.data)
if (message.event === 'content.published' && message.data.contentType === 'blogPost') tags.push('blog-feed')
tags.forEach((tag) => revalidateTag(tag, { expire: 0 }))
```

## Make sure the tags match

A page is only refreshed if the tag your website used when reading it is the tag the webhook names. The webhook names the page by its region key and its address as the CMS stores it. Check three things:

- **Use the region key, not its short code.** `defaultRegion: 'germany'`, not `'de'`. Both read the same content, but a page read with `de` is tagged `cms:page:de:/about`, while the webhook refreshes `cms:page:germany:/about`. Find the key in **Configuration › Environments**.
- **Always say which region.** Without `defaultRegion` or `region`, pages are tagged with `default`, which no webhook names. They only refresh on expiry, or when everything is refreshed.
- **Pass the address the CMS stores.** It starts with `/`, has no trailing slash except for the home page `/`, has no region prefix such as `/de`, and no query string. Build it from the route's segments: `'/' + slug.join('/')`.

Pages answered by another region's fallback are tagged with the region you asked for, but publishing names the region the page belongs to. They catch up on expiry. To refresh them at once, add those tags in your own `revalidate`.

## Your own fetch calls

Calls you make with `fetch` or another library are not tagged by the SDK. To have them refreshed by webhooks, label them with the SDK's tags:

```ts
import { cacheTags } from '@bifrost-orbit-cms/sdk'

const response = await fetch(`${process.env.CMS_API_URL}/v1/list?type=blogPost`, {
  headers: { Authorization: `Bearer ${process.env.CMS_PUBLIC_KEY}` },
  next: { revalidate: 60, tags: [cacheTags.all, cacheTags.lists] },
})
```

With `'use cache'` functions, call `cacheTag(cacheTags.all, cacheTags.lists)` from `next/cache` inside them.

## Refresh by hand

Sometimes you need everything fresh at once, such as after changing the website's code to read content differently. A deploy clears rendered pages, but some hosting keeps CMS answers across deploys. To clear both, add a protected route:

```ts
// app/api/refresh/route.ts
import { revalidateTag } from 'next/cache'
import { cacheTags } from '@bifrost-orbit-cms/sdk'

export async function POST(request: Request) {
  if (request.headers.get('authorization') !== `Bearer ${process.env.REFRESH_TOKEN}`) {
    return new Response('Unauthorized', { status: 401 })
  }
  const tag = new URL(request.url).searchParams.get('tag') ?? cacheTags.all
  revalidateTag(tag, { expire: 0 })
  return Response.json({ revalidated: [tag] })
}
```

```bash
curl -X POST -H "Authorization: Bearer $REFRESH_TOKEN" "https://www.example.com/api/refresh?tag=cms"
```

## Try it on your computer

Caching only behaves as described in a production build. `next dev` renders every page on every request, so build and start the website instead:

```bash
npm run build && npm run start
```

### Send a signed message yourself

You can call your webhook route without the CMS. Put any secret in `.env.local`, such as `CMS_WEBHOOK_SECRET=whsec_local_test`, start the website, then sign and send a message:

```bash
SECRET=whsec_local_test
BODY='{"event":"content.published","sentAt":"2026-10-19T09:30:00.000Z","data":{"url":"/about","region":"germany","contentType":"page"}}'
TS=$(node -e 'console.log(Date.now())')
SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')

curl -i -X POST http://localhost:3000/api/revalidate \
  -H 'Content-Type: application/json' \
  -H "x-orbit-timestamp: $TS" \
  -H "x-orbit-signature: $SIG" \
  --data "$BODY"
```

The answer is `200` with `{"revalidated":["cms:page:germany:/about","cms:list","cms:sitemap","cms:references"]}`. Change `BODY` to try other events, such as `{"event":"navigation.changed","sentAt":"…","data":{"key":"main"}}`.

### Receive real webhooks

The CMS can only call a public `https` address. Use a tunnelling service, such as Cloudflare Tunnel or ngrok, to give your computer one:

```bash
cloudflared tunnel --url http://localhost:3000
```

Create a webhook for the address it prints plus `/api/revalidate`, with **Environment** set to the one your local website reads, and put its secret in `.env.local`. Delete the webhook when you are done; it switches itself off after 20 failed deliveries anyway.

## Several websites and environments

- **One webhook per website.** A staging website and the live website each need their own webhook, with **Environment** set to Staging and Production, and each its own secret.
- **Several copies of one website.** If your website runs on several servers, each keeps its own cache unless they share one. On Vercel and Netlify the cache is shared for you. When hosting yourself, configure a shared cache handler, such as one backed by Redis, or run one copy; see [Deploy your website](https://docs.bifrostorbit.com/cms/developers/deploy-your-website/).
- **A CDN in front.** If your CDN caches HTML, it does not know about Next.js's tags. Give pages a short `s-maxage`, or purge the CDN from your own `revalidate` as shown above.

## When a page stays out of date

Work down this list:

1. **Did the webhook arrive?** Open **Configuration › Webhooks**, then **Deliveries** in the webhook's **⋯** menu. No row means the webhook is off, for another environment, or the event is not ticked.
2. **Was it accepted?** A `401` is a wrong secret or a clock more than five minutes out. See [Refresh the website when content changes](https://docs.bifrostorbit.com/cms/developers/webhooks/).
3. **Which tags did it refresh?** Point at the result to see your answer. Compare the page tag with the region and address your website reads.
4. **Is the client tagging?** `cache: { tags: true }` must be on the client that reads the page.
5. **Is the page read through the client?** Your own `fetch` calls need tags, as above.
6. **Is something else caching?** A CDN or the browser may hold the old HTML. Check the response headers.
7. **Does the region match?** See "Make sure the tags match" above.

If you cannot find it, the page still refreshes within `revalidate` seconds. [Troubleshooting your website](https://docs.bifrostorbit.com/cms/developers/troubleshooting/) has more.
