Skip to content

Guides

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.

How-to guide11 min read

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.

The caches

From the visitor back to the CMS:

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

lib/cms.tsTypeScript
export const cms = createCmsClient({  // …  cache: { revalidate: 60, tags: true },})
app/[[...slug]]/page.tsxTypeScript
export const revalidate = 60
cache.revalidate: 60
Means
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
Means
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
Means
Next.js's default for fetch applies. In Next.js 16 that means not cached, so every request calls the CMS.
cache.tags: true
Means
Each answer is labelled with tags, so webhooks can expire it. Without it, webhooks have nothing to refresh.
No cache at all
Means
Nothing is cached by the SDK. Every page view calls the CMS.
export const revalidate = 60
Means
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:

getPage, findPage, resolve
Tags
cms:page:<region>:<path>, such as cms:page:germany:/about
getNavigation, findNavigation
Tags
cms:nav:<key>, such as cms:nav:main
getGlobal, findGlobal
Tags
cms:global and cms:global:<key>
list, getProducts, getTaxonomy
Tags
cms:list
getRegions, getLocales
Tags
cms:regions
getSitemapIndex, getSitemap, getRobots
Tags
cms:sitemap
getPopups
Tags
cms:popups
getBanners
Tags
cms:banners
Any call with include: ['references']
Tags
also cms:references
getSchema, graphql
Tags
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:

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

A page is published, unpublished, deleted, rolled back or promoted
Event
content.published, content.unpublished, content.deleted
Tags refreshed
The page's tag, cms:list, cms:sitemap, cms:references
The same, for a page whose message has no region
Event
The same
Tags refreshed
cms, which is everything
A page is published at a new address
Event
content.published with previousUrl
Tags refreshed
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
Event
content.* with no url
Tags refreshed
cms:global, cms:list, cms:popups, cms:banners, cms:references
A shared section is published
Event
content.* with contentType: "sharedSection"
Tags refreshed
cms, because any page may show it
A menu is created, changed or deleted
Event
navigation.changed
Tags refreshed
cms:nav:<key>
A release runs
Event
release.published
Tags refreshed
cms:list, cms:sitemap, cms:references. Each page in the release also sends its own content.published.
The test button
Event
webhook.test
Tags refreshed
nothing
Any other event
Tags refreshed
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.
200
Body
{"revalidated":["cms:page:germany:/about","cms:list","cms:sitemap","cms:references"]}
When
The message was genuine; the list may be empty, as for a test
401
Body
{"error":{"code":"unauthorized","message":"Bad signature"}}
When
A header is missing, or the signature does not match the secret
401
Body
{"error":{"code":"expired","message":"The message is too old"}}
When
The timestamp is more than five minutes from your clock
500
Body
Next.js's error page
When
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 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:

app/api/revalidate/route.tsTypeScript
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:

TypeScript
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, then:

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

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

app/api/refresh/route.tsTypeScript
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] })}
Shell
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:

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

Shell
SECRET=whsec_local_testBODY='{"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:

Shell
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.
  • 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.
  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 has more.

Was this page helpful?

Read next