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:
Cache-Controlexport const revalidate in the pagerevalidateTag, or expirycache on the clientrevalidateTag, or expirycache)cache() around your call| 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
export const cms = createCmsClient({ // … cache: { revalidate: 60, tags: true },})export const revalidate = 60cache.revalidate: 60cache.revalidate: falsecache.revalidate left outfetch applies. In Next.js 16 that means not cached, so every request calls the CMS.cache.tags: truecache at allexport const revalidate = 60cache.revalidate.| 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:
getPage, findPage, resolvecms:page:<region>:<path>, such as cms:page:germany:/aboutgetNavigation, findNavigationcms:nav:<key>, such as cms:nav:maingetGlobal, findGlobalcms:global and cms:global:<key>list, getProducts, getTaxonomycms:listgetRegions, getLocalescms:regionsgetSitemapIndex, getSitemap, getRobotscms:sitemapgetPopupscms:popupsgetBannerscms:bannersinclude: ['references']cms:referencesgetSchema, graphqlgetSchema uses only cms and GraphQL is not cached| 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:
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:
content.published, content.unpublished, content.deletedcms:list, cms:sitemap, cms:referencescms, which is everythingcontent.published with previousUrlcms, because menus and links elsewhere now point at the new addresscontent.* with no urlcms:global, cms:list, cms:popups, cms:banners, cms:referencescontent.* with contentType: "sharedSection"cms, because any page may show itnavigation.changedcms:nav:<key>release.publishedcms:list, cms:sitemap, cms:references. Each page in the release also sends its own content.published.webhook.testcms| 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:
- Reads
x-orbit-timestampandx-orbit-signature, and the raw body as text. - 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.
- Refuses a message more than five minutes away from your server's clock.
- Parses the body and runs
tagsForEvent(event, data). - Calls
revalidateTag(tag, { expire: 0 })for each tag, so the next visitor gets fresh content rather than one more stale copy. - Answers with the tags it refreshed.
200{"revalidated":["cms:page:germany:/about","cms:list","cms:sitemap","cms:references"]}401{"error":{"code":"unauthorized","message":"Bad signature"}}401{"error":{"code":"expired","message":"The message is too old"}}500| 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 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:
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:
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:
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 withdeis taggedcms:page:de:/about, while the webhook refreshescms:page:germany:/about. Find the key in Configuration › Environments. - Always say which region. Without
defaultRegionorregion, pages are tagged withdefault, 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:
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:
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] })}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:
npm run build && npm run startSend 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:
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:
cloudflared tunnel --url http://localhost:3000Create 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 ownrevalidateas shown above.
When a page stays out of date
Work down this list:
- 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.
- Was it accepted? A
401is a wrong secret or a clock more than five minutes out. See Refresh the website when content changes. - 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.
- Is the client tagging?
cache: { tags: true }must be on the client that reads the page. - Is the page read through the client? Your own
fetchcalls need tags, as above. - Is something else caching? A CDN or the browser may hold the old HTML. Check the response headers.
- 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?