next.js app router stale isr data fix

Avatar
M

Maksudur Rahman

Software Engineer•
1Views
5mRead
0Reactions

Pages built with the App Router and ISR are supposed to refresh after the revalidate interval. In production they sometimes keep serving the old HTML even after the source data changes. This article walks through the next.js app router stale isr data fix you can drop into a Next.js 14+ TypeScript project and see fresh content instantly.

TL;DR / Quick Summary

  • Set revalidate on fetch and add cache: 'force-no-store' for critical paths.

  • Add cacheTags to the fetch call and enable experimental.cacheTag in next.config.js.

  • Deploy a Vercel webhook that calls /api/revalidate after content updates.

  • Verify with curl -I that x-nextjs-cache header shows MISS.

Reproducing the Stale ISR Symptom

Repo layout

my-next-app/
├─ app/
│  └─ blog/
│     └─ [slug]/
│        ├─ page.tsx          # ISR page
│        └─ layout.tsx
├─ pages/
│  └─ api/
│     └─ revalidate.ts        # on‑demand webhook
├─ next.config.js
└─ vercel.json

The page code (app/blog/[slug]/page.tsx)

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

export const revalidate = 60; // seconds

export default async function Page({ params }: { params: { slug: string } }) {
  const data = await fetch(`https://api.example.com/posts/${params.slug}`, {
    next: {
      tags: ['post'],
      revalidate: 60,
    },
  }).then(res => res.ok ? res.json() : null);

  if (!data) notFound();
  return (
    <article>
      <h1>{data.title}</h1>
      <p>{data.body}</p>
    </article>
  );
}

Deploy the repo to Vercel. Update a post via the CMS, wait a few seconds, then refresh the page. If the old title still shows, you’re looking at stale ISR data.

Why ISR Stales in Production

Vercel caches the generated HTML at the edge for the revalidate window. When the origin API returns a new payload, Vercel only swaps the cache after the next request passes the interval. Two hidden culprits make the window longer than you expect:

  1. Default fetch cache – without cache: 'force-no-store' the request may be served from the Vercel edge cache, bypassing the fresh API call.

  2. Missing cache tags – Vercel’s tag‑based invalidation only works when experimental.cacheTag is turned on in next.config.js.

Both cause the edge to keep the old HTML until the timer expires, which looks like a bug but is just a config gap.

Fix #1: Explicit Revalidate & Cache Tags

Update the fetch call to force a fresh request on every ISR run and attach a tag that you can purge on‑demand.

// app/blog/[slug]/page.tsx (updated)
const data = await fetch(`https://api.example.com/posts/${params.slug}`, {
  cache: 'force-no-store', // bypass edge cache for the fetch itself
  next: {
    tags: ['post'],
    revalidate: 30, // shorten the window for demo purposes
  },
}).then(res => res.ok ? res.json() : null);

Enable tag support (next.config.js)

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    cacheTag: true, // required for `next.tags`
  },
};
module.exports = nextConfig;

Now every ISR regeneration hits the API directly, and the tag post is attached to the generated HTML.

Fix #2: Vercel Edge Headers to Prevent Stale Reads

Add a vercel.json that tells the edge to treat the route as no-cache until the ISR rebuild finishes.

// vercel.json
{
  "routes": [
    {
      "src": "/blog/(.*)",
      "headers": {
        "Cache-Control": "public, max-age=0, s-maxage=0, stale-while-revalidate=0"
      }
    }
  ]
}

The header forces the CDN to fetch the fresh HTML on the next request, eliminating the visual lag.

Fix #3: On‑Demand Revalidation Webhook

For content that changes irregularly, push a webhook from your CMS that hits a Next.js API route.

// pages/api/revalidate.ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  // secret token protects the endpoint
  if (req.query.secret !== process.env.REVALIDATE_SECRET) {
    return res.status(401).json({ message: 'Invalid token' });
  }

  try {
    await res.revalidate('/blog/' + req.body.slug);
    return res.json({ revalidated: true });
  } catch (err) {
    return res.status(500).json({ message: 'Revalidation failed' });
  }
}

Configure your CMS to POST { slug: 'my-post' } to https://your-site.vercel.app/api/revalidate?secret=YOUR_SECRET after publishing. The edge cache is cleared instantly, so users never see stale data.

Request Flow Diagram

flowchart TD
    A[User Request] --> B{Edge Cache?
    stale?}
    B -- Yes --> C[Serve Cached HTML]
    B -- No --> D[Run ISR (fetch API)]
    D --> E[Generate HTML]
    E --> F[Store in Edge Cache]
    F --> C
    subgraph Revalidation Hook
        G[CMS Publish] --> H[POST /api/revalidate]
        H --> I[res.revalidate()]
        I --> F
    end

The diagram shows the normal edge‑cache path and the extra hook that forces a fresh store.

Comparison Table

Strategy

Freshness Guarantee

Complexity

Ideal Use‑Case

Default ISR (revalidate only)

Up to revalidate seconds stale

Low

Low‑traffic blogs

Force‑no‑store + tags

No stale HTML, API hit each rebuild

Medium

Content that must be accurate

On‑demand webhook

Immediate purge

High

CMS‑driven sites with frequent edits

Common Pitfalls & Troubleshooting

  • next: { tags: [...] } ignored – make sure experimental.cacheTag is true in next.config.js. Without it the tag header never appears.

  • Vercel still serves old HTML after webhook – check that the secret matches process.env.REVALIDATE_SECRET and that the route path (/blog/slug) matches the page URL exactly.

  • cache: 'force-no-store' breaks static caching – use it only on ISR pages that need fresh data; applying it globally will defeat the performance benefits of static generation.

  • revalidate value not respected – Node.js 20’s fetch polyfill may cache aggressively; always set cache option when you rely on tags.

If you see x-nextjs-cache: HIT after a webhook call, run curl -I with Cache-Control: no-cache to force a bypass and confirm the edge state.

FAQ

  • How do I test ISR locally without Vercel? Run next dev and hit http://localhost:3000/_next/data/<build-id>/blog/slug.json. The revalidate timer works the same way.

  • Can I combine ISR with getStaticProps in the App Router? No. The App Router uses Server Components; revalidate is declared as an export or via fetch options.

  • Why does next dev sometimes ignore revalidate? Development mode disables caching by design, so you’ll always see fresh data. The issue only appears in production builds.

  • Do Vercel Edge Functions respect Cache-Control headers from vercel.json? Yes. The headers block overrides the default CDN behavior for the matched route.


Connect with KoderSolution 🚀

Master modern software engineering, scalable architectures, and advanced backend systems with KoderSolution:

  • 🌐 Official Website: Read more architectural deep-dives and tutorials on kodersolution.com

  • 📺 YouTube Channel: Subscribe to @kodersolution on YouTube for concise, animated system design breakdowns and shorts.

  • 📱 Facebook Page: Follow our official Facebook Page for daily development tips, architecture discussions, and community updates.

💬 What's your setup? How does your production pipeline handle ISR invalidation—do you rely on on‑demand revalidation, shorter intervals, or a custom cache layer? Share your thoughts and join the discussion below!

Related Engineering Guides

Recommended Resources & Courses

Partner Links
Maksudur Rahman
M
Written by•Software Engineer

Maksudur Rahman

Senior Developer Advocate & System Lead Moderator.

React to this article