Step 7 of 10 (70% complete)
Cache Management & Revalidation
Step Code
The code for this specific step can be found on the following branch:
Caching is one of the most important topics for a CMS-driven site. Content changes in the CMS need to appear on the website without requiring a full rebuild. This starter uses a combination of React 19's 'use cache' directive and Next.js cache tags to achieve granular, on-demand revalidation.
The Caching Strategy
All content fetches in this starter use cacheLife('max'), which in Next.js maps to:
- Revalidate: 30 days — after 30 days, the next request triggers a background revalidation
- Expire: 1 year — after 1 year the cache entry is fully removed regardless
For a CMS-driven site, this is the right default. Pages should be fast and served from cache, while the webhook-based revalidation handles fresh content as soon as it is published.
On-Demand Revalidation via Webhook
When an editor publishes content in Optimizely, the CMS sends a POST request to your revalidation webhook:
POST /api/revalidate?cg_webhook_secret=<YOUR_SECRET> { "data": { "docId": "<guid>_<locale>_Published" } }
The app/api/revalidate/route.ts handler:
- Validates the shared secret
- Extracts the content GUID and locale from the payload
- Queries Optimizely Graph to resolve the published content's URL
- Calls
revalidatePath()orrevalidateTag()depending on the content type
async function handleRevalidation(urlWithLocale: string, locale: string) { if (urlWithLocale.includes('footer')) { revalidateTag(getCacheTag(CACHE_KEYS.FOOTER, locale), 'max') } else if (urlWithLocale.includes('header')) { revalidateTag(getCacheTag(CACHE_KEYS.HEADER, locale), 'max') } else { revalidatePath(urlWithLocale) } }
Registering the Webhook in Optimizely
Before revalidation can work, you need to tell Optimizely where to send publish notifications. Webhooks are registered via the Content Graph REST API.
Full documentation: https://docs.developers.optimizely.com/platform-optimizely/docs/manage-webhooks
Send a POST request to https://cg.optimizely.com/api/webhooks, authenticated with your OPTIMIZELY_GRAPH_SINGLE_KEY:
curl -X POST https://cg.optimizely.com/api/webhooks \ -H "Authorization: Basic <your-base64-encoded-single-key>" \ -H "Content-Type: application/json" \ -d '{ "disabled": false, "request": { "method": "POST", "url": "https://your-site.com/api/revalidate?cg_webhook_secret=<OPTIMIZELY_REVALIDATE_SECRET>" }, "topic": ["doc.updated"], "filters": { "status": { "eq": "Published" } } }'
Key fields:
request.url— your full revalidation endpoint URL including thecg_webhook_secretquery param matching yourOPTIMIZELY_REVALIDATE_SECRETtopic—"doc.updated"fires on single content changes; add"bulk.completed"to also catch bulk publish operationsfilters.status— restricts events to published content only; without this you receive notifications for drafts and other status changes too
To list all registered webhooks: GET https://cg.optimizely.com/api/webhooks
To remove one: DELETE https://cg.optimizely.com/api/webhooks/{id}
Regular Pages — Path-Based Revalidation
For all standard CMS pages (everything that is not the header or footer), revalidation is done by path:
revalidatePath(urlWithLocale)
The webhook resolves the published content's URL from Optimizely Graph and calls revalidatePath with it. Next.js then marks that specific route as stale — on the next request, it re-renders the page and updates the cache. This is the simplest and most direct strategy: one publish → one path invalidated.
Header and Footer as Separate CMS Pages
The header and footer cannot use path-based revalidation. They are shared across all pages — calling revalidatePath for every single page when only the header changes would be wasteful and imprecise.
The solution is elegant: the Header and Footer are treated as separate CMS pages (with baseType: '_page'). They each have their own path in the CMS (e.g. /en/header/ and /en/footer/). When the editor publishes the header, the webhook detects it, and calls revalidateTag('optimizely-header-en', 'max') — invalidating only the header cache for that locale.
Cache tags are attached at fetch time:
async function getHeaderContent(locale: string) { 'use cache' cacheLife('max') cacheTag(getCacheTag(CACHE_KEYS.HEADER, locale)) // getCacheTag returns e.g. 'optimizely-header-en' ... }
Extending This Pattern: SiteSettings
The header/footer pattern is just one example of this approach. You can apply the same strategy to any shared, CMS-managed configuration.
For example, imagine you store Algolia configuration (app ID, index name, API key) per language in the CMS. You would create a SiteSettings page type:
export const SiteSettingsContentType = contentType({ key: 'SiteSettings', displayName: 'Site Settings', baseType: '_page', properties: { algoliaAppId: { type: 'string' }, algoliaSearchKey: { type: 'string' }, algoliaIndexName: { type: 'string', localized: true }, }, })
Create a page at /en/site-settings/ in the CMS, fetch it with a cache tag, and revalidate it via webhook exactly like header and footer. The CMS becomes the configuration source, and editors can update values per language without a deployment.
Cache Keys
All cache keys live in lib/cache/cache-keys.ts as typed constants:
export const CACHE_KEYS = { FOOTER: 'optimizely-footer', HEADER: 'optimizely-header', } as const export function getCacheTag(baseKey: ..., locale: string): string { return `${baseKey}-${locale}` // e.g. 'optimizely-header-en' }
Always use getCacheTag(CACHE_KEYS.HEADER, locale) — never hardcode tag strings. This ensures the webhook and the fetch function always use the same tag.
Have questions? I'm here to help!