September 25, 2026
How Next.js ISR Powers a Content-Heavy Travel Site - And How It Can Quietly Go Stale
The Problem: Static Speed vs. Fresh Content
I was working on a feature where the marketing team needed to update a package's cover image directly from the ERP - no deploy, no waiting, just a quick edit that should reflect on the customer-facing platform right away. I made the change, checked the public page to confirm it worked, and the old image was still there.
I brought the issue to a colleague, who introduced me to ISR - what it is, and why and how it's being used on the site. That sent me digging into the actual implementation myself, and while going through it and understanding how it worked, I realized I was missing a shared secret key on my end - required on both the backend and frontend for the revalidation webhook to work. I added it, and the refresh started working immediately. The deeper I went into the implementation after that, the more it turned into a genuinely good example of how ISR works, and how quietly it can fail when one small piece of config is missing - worth writing up properly.
A marketing site's destination pages want two things that normally trade off against each other.
They want to be fast - pre-rendered HTML served from the edge, no database round-trip per visitor, good Core Web Vitals, cheap to serve at whatever traffic a campaign sends. That argues for static generation at build time.
They also want to be current - a staff member publishes a new package or edits a destination's hero copy in the internal admin console, and that should show up on the public site without waiting for the next deploy. That argues for rendering on every request.
Pure static site generation (next build bakes every page once) gets the speed but not the freshness - a content edit sits invisible until someone redeploys. Pure server-side rendering gets the freshness but not the speed - every visitor triggers a live fetch to the backend, and a traffic spike becomes a database load spike.
Incremental Static Regeneration is Next.js's answer: serve the cached, static version of a page to everyone, but give yourself a way to regenerate that cache without a redeploy.
The Mechanism: Cache Until Told Otherwise
The destination page opts in with two lines:
export const revalidate = false;
export async function generateStaticParams(): Promise<{ slug: string }[]> {
return [];
}Neither is the textbook ISR example, and that's deliberate.
generateStaticParams returning an empty array means no destination page is pre-built at deploy time. The first visitor to a given destination triggers the render; Next.js caches that output and serves it to everyone after. This keeps the build independent of the backend - a new destination doesn't need a frontend deploy to appear, and the backend being unreachable at build time can't fail the whole site build.
revalidate = false is the more interesting choice. The textbook ISR setup picks a time window - regenerate the page at most once an hour, say. This setup actually shipped a time-based version first: a weekly backstop, running alongside on-demand invalidation. Once the on-demand path proved reliable, the weekly timer was dropped entirely in favor of pages that cache indefinitely until something explicitly tells Next.js to throw the cached copy away. No timer means no wasted regenerations for pages nobody edited - but it also means the on-demand signal is now the only thing standing between a content edit and a stale page. There's no clock quietly fixing it an hour later.
The Webhook: The Backend Tells the Site What Changed
If the frontend never re-checks the source of truth on its own, something else has to tell it when to. That something is a small POST route on the frontend, called by the backend right after a write succeeds.
The route checks a shared secret sent as a header (never a query param - query strings end up in access and proxy logs), then calls Next.js's revalidatePath for whatever that entity actually affects:
case "package": {
if (!body.slug || !body.countrySlug) return false;
revalidatePath(`/packages/${body.slug}`);
revalidatePath(`/destinations/${body.countrySlug}`);
revalidatePath("/");
return true;
}A package touches its own detail page, its destination's grid, and the homepage's featured row - so a package edit flushes all three. The route, not the backend, decides this. That's the point: which pages a package change invalidates is frontend routing knowledge, not backend knowledge.
On the backend side, a revalidation service fires this after content mutations:
const response = await fetch(`${this.frontendUrl}/api/revalidate/${entity}`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-revalidate-secret": this.secret,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(3000),
});And here's the part that matters most, straight from a comment in the code: "Best-effort by design - content mutations must never fail because the marketing site was unreachable. There is no time-based fallback, so a miss here leaves stale content until someone runs a manual refresh."
That's exactly the bug I chased. The shared secret has to match on both sides for the webhook to be accepted - the frontend route returns a plain 401 if it doesn't, and the backend just logs a warning and moves on. Nothing throws, nothing shows up as an error to the person who edited the package, because from the backend's point of view the actual database write succeeded. The image was updated. It just never told the cache. With no weekly timer left to eventually paper over it, the stale page sits there until either the secret gets fixed or someone manually flushes it - which, in a fresh environment where these values were never wired to match, looks indistinguishable from a broken feature.
Getting the Granularity Right
The design didn't arrive fully formed - it was tightened twice, in both directions.
Dropping the timer. The first version paired the weekly backstop with on-demand flushing, on the theory the timer would catch anything the webhook missed. A later review made the call to drop it, once every content mutation reliably fired its webhook - the timer wasn't protecting against anything real anymore, it was just quietly regenerating unchanged pages once a week for no reason. Removing it made the caching story consistent across the whole site: everything is on-demand-only, no exceptions to remember.
Narrowing the blast radius. A follow-up change added a new entity type to the revalidate route after a related edit needed its own invalidation path. The reasoning: that entity renders only on one specific page, and the listing pages don't carry any field that could reference it - so neither of those could go stale from that edit. Rather than reusing the broader package case, which also flushes the destination grid and homepage, the narrower edit flushes only the one page that could possibly show that data. Over-invalidating isn't free - every revalidatePath call means Next.js throws away work and re-renders on the next request, so flushing three pages to fix data that only lives on one is waste that adds up as edits get more frequent.
The pattern in both changes is the same: don't invalidate more than the data's actual blast radius, and don't run recovery logic for failure modes that no longer happen.
Why ISR Was the Right Call, and the Trade-off It Accepts
Destination and package pages are exactly the shape of content ISR is for: public, high-traffic, rarely edited relative to how often they're viewed, and each one has a clear owner-triggered moment (a staff member hits save) when it actually needs to change. That last part is what makes pure on-demand caching viable instead of just theoretically nice - there's always a specific write that knows exactly which pages just went stale, so nothing has to guess on a timer.
The trade-off is the one this whole post has been circling: on-demand-only ISR moves the correctness burden from "eventually, a timer fixes it" to "the webhook has to actually land." That's a good trade when the webhook is reliable - zero wasted regeneration, pages that update within seconds of a real edit, faster than a weekly timer could ever manage. But it also means a misconfigured shared secret, a brief frontend outage, or a network blip during that timeout window all produce the exact same symptom: content that looks silently, indefinitely frozen, with no error surfaced anywhere a person would think to look. A manual "revalidate all" option exists in the admin settings precisely as the escape hatch for this - the one revalidation call that does throw on failure, because a human is standing there waiting for a result.
The Lesson
When a system replaces a safety-net timer with an explicit signal, the signal's failure mode becomes the whole system's failure mode. Any new environment wiring a backend to a frontend instance - a local worktree, a staging stack, a fresh deploy - needs the shared revalidate secret set identically on both sides from the start, or it'll look exactly like a caching bug that isn't one.