</>CodeWithKarani

export const revalidate Is Ignored in Production: How to Find Out Why

Karani GeoffreyKarani Geoffrey10 min read

A client updates a product price in their CMS. Forty minutes later the site still shows the old price. You open the page file and there it is, exactly where the docs said to put it:

export const revalidate = 60

It worked when you tested it. next build && next start on your laptop, wait a minute, refresh, new price. In production the page only changes when you redeploy. There is an open issue on the Next.js repository, #61923, with almost exactly that title, and it has been sitting there since 14.1 with no fix.

Here is the thing that took me too long to internalise: revalidate is not a timer. It is a maximum-staleness hint handed to a cache, and in production there are usually four caches between that export and the HTML a human actually receives. Until you know which one answered the request, you are guessing, and every "fix" you try is a coin flip.

work down this list in order, do not skip.

  • Read the next build output. If your route has no Revalidate value, or is marked ƒ (Dynamic), Next.js never applied your export at all.
  • The value must be a literal. export const revalidate = 60 * 10 and = Number(process.env.TTL) are not statically analysable and are ignored without warning.
  • ISR is stale-while-revalidate. The first request after the window expires still returns the stale page and only kicks off regeneration in the background. Refresh twice before you declare it broken.
  • Read the x-nextjs-cache response header. Values are HIT, STALE, MISS and REVALIDATED. If you never see STALE, something in front of Next.js is answering.
  • Self-hosting on more than one container? The default cache is per instance. Instance A regenerates, instance B serves the old page indefinitely.

The symptom: revalidate works in next start but is ignored in production

There is no error message here, which is exactly why this is hard. Nothing logs, nothing throws, the build passes. The only evidence you get is a page that is older than it should be. So the first job is to turn a vague symptom into a signal.

The single most useful signal is the build output. A recent next build prints a route table with Revalidate and Expire columns, roughly like this:

Route (app)                        Size  First Load JS  Revalidate  Expire
┌ ○ /                            5.2 kB         105 kB
├ ● /blog/[slug]                 2.1 kB         102 kB         1m         1y
└ ƒ /dashboard                   3.4 kB         104 kB

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand

If the route you care about shows an empty Revalidate cell, Next.js did not read your export. If it shows ƒ, the route is dynamic and revalidation is meaningless because nothing is being cached in the first place. Either way you have stopped guessing, and that is worth more than any config change.

Any of these can hand back an old page. revalidate only governs one of them. 1. Browser cache Obeys Cache-Control. A hard refresh is not the same as what your visitor sees. 2. CDN, edge network or nginx proxy_cache Has its own TTL. A custom Cache-Control header in next.config overrides ISR here. 3. Next.js full route cache (ISR) This, and only this, is what export const revalidate configures. 4. Next.js data cache Per-fetch revalidate and tags. A lower value here wins for the whole route. 5. Your API, CMS or database If the CMS itself caches its API response, Next.js faithfully caches stale data.
Debugging in the wrong layer is why this problem eats whole afternoons. Identify the layer first, change config second.

Why this happens

The value has to be statically analysable, and nothing tells you when it is not

The Next.js docs are explicit about this: "The revalidate value needs to be statically analyzable. For example revalidate = 600 is valid, but revalidate = 60 * 10 is not." That is the whole rule, and it is enforced by the compiler reading your file, not by running your code.

Which means every one of these is silently dead:

// All of these are ignored. No warning, no build error.
export const revalidate = 60 * 10
export const revalidate = Number(process.env.REVALIDATE_SECONDS)
export const revalidate = isProd ? 300 : 0
export const revalidate = CACHE_TTL          // imported from another file

The last one catches people who are trying to be tidy. Pulling the number into a shared constants file feels like good practice and quietly disables ISR on every route that imports it. If you want one number in one place, use a codegen step or accept the duplication. Do not import it.

ISR is stale-while-revalidate, not a cron job

This is the second-biggest cause of false alarms. From the Next.js ISR guide, the sequence after the window expires is:

After 60 seconds has passed, the next request will still return the cached (now stale) page. The cache is invalidated and a new version of the page begins generating in the background. Once generated successfully, the next request will return the updated page.

So the request that "proves" it is broken is the request that triggers the fix. If you update the CMS, wait past the window, refresh once and see old content, that is correct behaviour. Refresh again a second or two later and you should see the new page. On a low-traffic site this looks indistinguishable from a bug, because nobody is generating the triggering request except you.

build page cached t + 0s to 60s x-nextjs-cache: HIT 1st request after 60s STALE served, regen starts in background 2nd request HIT, fresh content The refresh that looks like the bug is the refresh that fixes it.
On a quiet site you are the only traffic, so you personally have to generate two requests before you see new content.

The lowest revalidate on the route wins

The route-level export is a default, not an override. The docs put it plainly: the route-level revalidate "does not override the revalidate value set by individual fetch requests", and "the lowest revalidate across each layout and page of a single route will determine the revalidation frequency of the entire route".

That cuts both ways. A revalidate = 0 or cache: 'no-store' on one fetch buried in a shared layout makes the whole route dynamic, and your revalidate = 3600 becomes decoration. Equally, a parent layout with revalidate = 60 pulls your one-hour page down to a minute.

Something in front of Next.js is answering

This is the production-only half, and it is where the open GitHub issue lives. Next.js sets its own Cache-Control on ISR responses. If you have added a blanket header in next.config.js, you have overwritten it:

// This quietly disables ISR behaviour at the edge.
async headers() {
  return [{ source: '/(.*)', headers: [
    { key: 'Cache-Control', value: 'public, max-age=3600' },
  ]}]
}

Same story with a Cloudflare page rule, an nginx proxy_cache_valid, or any CDN with "cache everything" turned on. The CDN happily serves its own copy for its own TTL and Next.js never receives the request that would have triggered regeneration. The page is frozen not because ISR failed but because ISR was never asked.

Multiple instances, multiple caches

If you self-host on more than one container, this one will get you eventually. The Next.js docs are unambiguous: "When running multiple instances, the default file-system cache is per-instance. On-demand revalidation only invalidates the instance that receives the call." Behind a load balancer, one pod regenerates and the others keep serving the version they built at boot. Users see the new price and the old price on alternate refreshes, which is the most confusing possible failure mode. The fix is a shared cache handler, not a bigger timeout.

The fix, step by step

Step 1: Prove Next.js read the value

npx next build 2>&1 | tee build.log
grep -n "Revalidate" -A 40 build.log

Expect to see your route with a value in the Revalidate column. If the cell is empty, go back and make the export a bare numeric literal in the page or layout file itself. Rebuild. The column should now be populated.

Step 2: Turn on cache logging locally

NEXT_PRIVATE_DEBUG_CACHE=1 npx next start

This makes the Next.js server log ISR cache hits and misses to the console. Hit the page, wait past the window, hit it twice more. You should see a hit, then a stale entry with a regeneration, then a hit on the fresh copy. If that sequence is correct locally, your Next.js config is fine and the problem is downstream.

Step 3: Read the response headers in production

curl -sI https://example.com/blog/some-post | grep -Ei 'x-nextjs-cache|cache-control|age|x-vercel|cf-cache-status'

What you are looking for:

What you seeWhat it means
x-nextjs-cache: HIT then STALE then HITISR is working. Your problem is elsewhere, probably the CMS.
x-nextjs-cache absent entirelyA CDN or proxy is answering. Next.js never saw the request.
x-nextjs-cache: MISS every timeThe route is dynamic. Check for cookies(), headers() or a no-store fetch.
A large and growing Age headerAn intermediate cache is holding the response past your window.

Step 4: Stop overriding Cache-Control

Remove any blanket Cache-Control header rule that matches your ISR routes, and scope CDN caching rules to /_next/static and your own asset paths rather than everything. Next.js already emits correct headers for ISR pages; your job is to not fight them.

Step 5: If you self-host on more than one instance, share the cache

Configure a custom cache handler backed by Redis or object storage so every instance reads and writes the same cache entries, and set cacheMaxMemorySize: 0 to stop each instance keeping its own in-memory copy on top. This is the only real fix for the alternating-content symptom.

Step 6: Consider on-demand revalidation instead

Time-based ISR is a compromise for content you cannot get an event for. If your CMS can fire a webhook, revalidatePath in a route handler is strictly better: the page updates when the content changes, not up to an hour later. The Next.js docs recommend high revalidation times precisely because on-demand is the better tool for precision. If you are wiring that webhook up, make it idempotent and verify its signature, the same discipline you would apply to any callback that can fire twice.

Verification

Do this against production, not your laptop:

# 1. Warm the cache
curl -sI https://example.com/blog/some-post | grep -i x-nextjs-cache
# expect: x-nextjs-cache: HIT

# 2. Change the source content, then wait out the window

# 3. First request after expiry
curl -sI https://example.com/blog/some-post | grep -i x-nextjs-cache
# expect: x-nextjs-cache: STALE

# 4. A second or two later
curl -s https://example.com/blog/some-post | grep -o 'new content marker'
# expect: the updated content

If step 3 never returns STALE, you have not fixed it, you have moved it. Go back to step 3 of the fix and find out who is answering.

What people get wrong

"Set revalidate = 1 to force it." This does not force anything. It shortens the staleness window to a second, which on a busy site means regenerating the page constantly and paying compute for it, and on a quiet site changes nothing at all because there is still no request to trigger regeneration. If you need the page to be current on every request, the honest answer is export const dynamic = 'force-dynamic' and accepting the cost.

"Add export const dynamic = 'force-dynamic' and revalidate = 60 together." These contradict each other. force-dynamic renders per request; there is nothing left to revalidate. You now have a page with no caching at all, which does look like your content updates, and will look very different on your hosting bill.

"Redeploy to bust the cache." It works, which is why people keep doing it, and it teaches you nothing. Worse, it hides the multi-instance problem completely, because a redeploy rebuilds every instance at once. If your fix for stale content is a deploy, you do not have ISR, you have a manual publish button with extra steps.

"It is a Vercel bug." Sometimes, historically, it has been. But before you file the issue, make sure your Revalidate column is populated and your x-nextjs-cache header behaves. In the reports I have read carefully, most resolve to a non-literal value, a no-store fetch in a layout, or a CDN rule someone added six months earlier.

When it is still broken

  • Check the runtime. ISR is only supported on the Node.js runtime. If the segment exports runtime = 'edge', the revalidate value is not available at all.
  • Check for a static export. output: 'export' produces plain files. There is no server, so there is no revalidation, and nothing will ever tell you that.
  • Check the upstream response. Add logging: { fetches: { fullUrl: true } } to next.config.js and confirm the fetch is actually returning new data during regeneration. A CMS that caches its own API for an hour will make a perfectly healthy ISR setup look dead.
  • If you are on Next.js 16 with Cache Components enabled, note that dynamic, dynamicParams, revalidate and fetchCache were removed under that flag. Your export is not being ignored, it no longer exists. You want cacheLife and the new caching model instead.

Architecturally, the lesson I have taken from this is to treat any ISR window longer than a few minutes as a business decision that someone non-technical has to agree to, and to write it down. "Prices update within one hour" is a supportable statement. "It should update automatically" is how you end up on a call at 9pm explaining a cache to a finance director.

Frequently asked questions

Why does export const revalidate work with next start but not in production?
Almost always because something sits in front of Next.js in production that does not exist locally. A CDN, edge network or nginx proxy_cache with its own TTL will serve its own copy and Next.js never receives the request that would trigger regeneration. Check with curl whether the x-nextjs-cache header is present on the production response at all; if it is missing, a proxy is answering, not Next.js.
Can I set revalidate from an environment variable?
No. Next.js reads the revalidate export at build time by statically analysing the source, so it must be a bare numeric literal or false. Number(process.env.TTL), 60 * 10, a ternary, and a constant imported from another file are all ignored without any warning or build error. If you need different values per environment, generate the file or duplicate the literal in each route.
I waited past the revalidate window and refreshed, and the page is still old. Is it broken?
Probably not. ISR is stale-while-revalidate: the first request after the window expires still returns the cached stale page and only starts regeneration in the background. The updated page appears on the next request. Refresh a second time a couple of seconds later before concluding anything, and check whether the first response carried x-nextjs-cache: STALE.
Why do I see old and new content on alternate refreshes when self-hosting Next.js?
You are running more than one instance and each one has its own file-system cache. Next.js documents that the default cache is per instance and that on-demand revalidation only invalidates the instance that receives the call, so a load balancer will bounce you between a regenerated pod and stale ones. Configure a shared custom cache handler backed by Redis or object storage so all instances read the same entries.
#Next.js#ISR#Caching#React#CDN
Keep reading

Related articles