Step 7 of 10 (70% complete)

Cache Management & Revalidation

Step Code

The code for this specific step can be found on the following branch:

Click on a link to view the code for this step on GitHub.

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:

  1. Validates the shared secret
  2. Extracts the content GUID and locale from the payload
  3. Queries Optimizely Graph to resolve the published content's URL
  4. Calls revalidatePath() or revalidateTag() 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 the cg_webhook_secret query param matching your OPTIMIZELY_REVALIDATE_SECRET
  • topic"doc.updated" fires on single content changes; add "bulk.completed" to also catch bulk publish operations
  • filters.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!

Contact Me