A Next.js App Router site prerenders its marketing pages from a headless CMS, and editors complain that a publish takes up to an hour to appear on the live site. Design the path from a CMS publish event to the updated page in production.
answer
- the CMS has to tell you
- an endpoint you must protect
- name the entity, not the URLs
- keep the window as a safety net
- one instance invalidated is not all instances
basics
~20 sHave the CMS call a Route Handler in the deployed app on publish. The handler verifies a shared secret, maps the payload to the tags or paths the change touches, calls revalidateTag or revalidatePath, and returns quickly. Keep a time window as a backstop.
solid answer
~50 sThe hour is the time-based window doing its job — it was never going to be faster than the number configured. To make publishes land immediately you need the CMS to *tell* the app something changed. Expose a Route Handler, say `app/api/revalidate/route.ts`, and register it as a webhook in the CMS. The handler must authenticate the caller before doing anything — a shared secret compared in constant time, or the CMS's signature scheme — because an open invalidation endpoint is a free cache-busting denial-of-service against your origin. It then maps the webhook payload to what changed (`revalidateTag('post-' + id)` plus a collection tag, or `revalidatePath` for a couple of known aggregate routes), returns a 200 fast, and lets the next request for each route do the rendering. Keep a generous time-based window in place as a backstop for webhooks that never arrive, and log every invalidation so a silently broken hook is visible before an editor reports it.
code
typescript · 15 lines// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
if (request.headers.get('x-webhook-secret') !== process.env.CMS_WEBHOOK_SECRET) {
return NextResponse.json({ ok: false }, { status: 401 })
}
const { id } = await request.json()
revalidateTag(`post-${id}`)
revalidateTag('posts')
return NextResponse.json({ ok: true, revalidated: [`post-${id}`, 'posts'] })
}go deeper
Know that a prerendered page does not refresh because the CMS changed — something has to tell the app. Naming a webhook endpoint that calls a revalidation API is a solid answer here.
Describe the concrete pieces: a Route Handler under app/api/, a secret check, mapping the payload to a tag, and calling revalidateTag. Explain that the page re-renders on the next request, not inside the handler.
Treat it as a production design: authentication and rate limiting on the endpoint, idempotency under webhook retries, logging so a dead hook is visible, and a time window retained as a backstop for lost events.
Own the contract between the CMS and the app — who defines tag names, what happens during bulk imports, and how invalidation stays correct across multiple instances and deployments. Decide what freshness the business is actually buying and what it costs.
## Diagnose before designing An hour of lag with prerendered pages almost always means a time-based window of about an hour, plus the fact that nothing acts on an expired entry until a request arrives. No amount of shrinking that number produces "instant": it only shortens the wait and multiplies regenerations. Immediacy requires an *event*, so the design question is how a publish in the CMS becomes a call into the deployed app. ## The shape of the solution ```ts // app/api/revalidate/route.ts import { revalidateTag } from 'next/cache' import { NextRequest, NextResponse } from 'next/server' export async function POST(request: NextRequest) { const secret = request.headers.get('x-webhook-secret') if (secret !== process.env.CMS_WEBHOOK_SECRET) { return NextResponse.json({ ok: false }, { status: 401 }) } const body = await request.json() revalidateTag(`post-${body.id}`) revalidateTag('posts') return NextResponse.json({ ok: true, revalidated: body.id }) } ``` Four things are happening: authenticate, interpret the payload, invalidate, respond. Each is worth its own scrutiny. ## Authenticate first An unauthenticated invalidation endpoint is a public button that empties your cache. An attacker looping on it forces every route to re-render on its next request, converting a cheap static site into full-rate load against your CMS and database. Use the CMS's signature verification if it offers one; otherwise a secret from the environment, compared without leaking timing, and never accepted from a query string where it will land in access logs. Rate-limit the endpoint too — a legitimate CMS does not publish a thousand times a minute. ## Interpret the payload into a tag, not a URL list The webhook knows what document changed. It does not know your routing. Ask it to name the entity — `post-42`, `author-7`, `posts` — and let the pages that read that data carry the matching labels. Then a publish invalidates the article page, the index, the home-page teaser and the sitemap without the webhook ever learning that those routes exist. Deriving tag strings from one shared helper rather than typing literals in both the reader and the webhook is what keeps the two sides in agreement; a typo here fails silently, which is the single most common way this setup breaks. Use `revalidatePath` alongside it for the small number of aggregate routes that are easier to name than to tag. ## Respond fast, expect retries Invalidation returns as soon as entries are marked; nothing renders inside the handler, so the response should be quick. Most CMSes retry failed webhooks, so the handler must be idempotent — invalidating an already-invalid entry is harmless, which makes that easy. Return a body naming what was invalidated: it turns "did the hook fire?" into something an editor or an on-call engineer can check. ## Keep the window as a backstop Webhooks get lost — a deploy is mid-rollout, the endpoint 500s, someone edits a record directly in the database. Leaving a time-based window in place means the worst case degrades from "stale forever" to "stale until the window elapses". Belt and braces is the right posture here: the event path is the fast path, and the window is the guarantee. ## Operational details that decide whether this works - **Logging and alerting.** Log every received webhook and every invalidation. A hook that silently stopped firing looks identical to a healthy system until an editor complains. - **Multiple instances.** If the app runs as several server instances each with its own on-disk cache, a webhook reaching one of them invalidates only that one, and users hitting other instances still see old content. Sharing the cache — a shared cache handler configured through `next.config`, or a platform that provides a shared cache — is what makes on-demand invalidation correct in a multi-instance deployment. This is the failure mode that most often survives a working local demo. - **Preview versus publish.** Editors usually also want to see unpublished drafts. That is a separate mechanism — `draftMode` from `next/headers`, which bypasses cached output for the editor's session — and confusing it with invalidation leads to teams invalidating on every keystroke in the editor. - **Bursts.** A bulk import firing one webhook per row can invalidate a collection tag thousands of times, and then every affected route re-renders on its next request at once. Coalescing bursts, or reserving broad tags for genuinely broad changes, keeps a content migration from looking like an outage. ## The answer in one breath "The hour is the window. Add an authenticated Route Handler the CMS calls on publish; it maps the changed entity to a tag and calls `revalidateTag`, the next request re-renders, and I keep a window as a backstop plus logging so a dead webhook is visible." Then mention the shared-cache caveat — that is the detail that says you have run this in production.
- Why not just drop the revalidation window to a few seconds instead of building a webhook?Because it trades one problem for a worse one. A tiny window makes every trafficked route re-render constantly, putting near-request-rate load on the CMS, and it still cannot make an edit appear instantly. The webhook makes cost proportional to *changes* rather than to traffic, which is the right scaling axis for content that changes rarely and is read often.
- What is the risk of leaving the revalidation endpoint unauthenticated?Anyone who finds it can invalidate your cache in a loop, forcing full re-renders and pushing origin-rate traffic onto your CMS and database — a cheap denial-of-service against an otherwise static site. It also leaks structure, since responses reveal which tags and paths exist. Verify a signature or a secret from the environment, and rate-limit the route.
- The webhook works locally but production still serves old pages. Where do you look first?At how many instances serve the app and whether they share a cache. With per-instance caches, a webhook lands on one instance and only that one re-renders; requests routed elsewhere still hit stale entries, which matches the intermittent 'sometimes updated, sometimes not' symptom exactly. Fix it with a shared cache handler or a platform-provided shared cache.
- Editors also want to preview unpublished drafts. Is that the same mechanism?No. Invalidation is about published content reaching everyone; previewing a draft is about one editor's session bypassing cached output, which is what `draftMode` from `next/headers` provides. Keeping them separate matters — otherwise teams start invalidating the public cache on every editorial keystroke, which is both wasteful and a way to leak unpublished content.
saying these in an interview costs you the question
- Shrinks the revalidation window instead of adding an event path
- Exposes the revalidation endpoint with no authentication
- Makes the webhook compute the list of affected URLs
- Expects the handler to render pages before responding
- Ignores that multiple instances may hold separate caches
- Confuses draft preview with cache invalidation