Medium Next.js

Next.js App Router Caching: fetch Cache, revalidate, ISR, revalidateTag and updateTag

Stale prices and leaked user data both come from misunderstood caching. Learn the Next.js layers, ISR, tags, updateTag vs revalidateTag, and when a route goes dynamic.

30 Oct, 2026 10 min Updated 23 Oct, 2026 8 Code blocks
Diagram
graph TD A[Visitor requests a page] --> B[Full Route Cache] B -->|fresh| C[Serve cached HTML] B -->|stale| D[Serve stale HTML and regenerate] B -->|miss or dynamic route| E[Render on the server] E --> F[Data Cache for fetch] F -->|miss| G[Origin API or database] H[revalidateTag or updateTag] -->|mark stale or expire| F H -->|affected routes| B

The marketing team changes a product price in the CMS, refreshes the storefront, and still sees the old price. Ten minutes later it is right. Meanwhile, on a different page, a logged-in customer briefly saw another customer's order list because someone added cache: 'force-cache' to a fetch that carried an auth header. Both bugs come from the same place: not knowing which layer of the Next.js App Router cache is holding what, and for how long.

The App Router gives you powerful caching, but it is explicit now. Since Next.js 15, fetch is not cached unless you ask, and Next.js 16 tightened the revalidation APIs (revalidateTag takes a profile, updateTag was added for Server Actions). This tip covers the model most production apps still run on: fetch caching, segment-level revalidate, ISR, and on-demand invalidation. Cache Components ('use cache', behind the cacheComponents flag) is a separate opt-in model and is mentioned only where it changes the advice.

The Layers, From Request to Origin

There are three server-side ideas to keep apart:

  • Request memoization. Identical GET fetches during one render are executed once. It lasts for a single render pass and needs no configuration.
  • Data Cache. The persistent cache for fetch results (and unstable_cache results). It survives across requests and deployments on platforms that persist it. You opt in with cache: 'force-cache' or next.revalidate.
  • Full Route Cache. The rendered HTML and RSC payload of a static route. This is what ISR regenerates.

On top of that, the browser keeps a client-side router cache for navigations, which revalidatePath and updateTag also refresh when called from a Server Action.

graph TD A[Visitor requests a page] --> B[Full Route Cache] B -->|fresh| C[Serve cached HTML] B -->|stale| D[Serve stale HTML and regenerate] B -->|miss or dynamic route| E[Render on the server] E --> F[Data Cache for fetch] F -->|miss| G[Origin API or database] H[revalidateTag or updateTag] -->|mark stale or expire| F H -->|affected routes| B

Caching fetch and Tagging Data

Put data access in one module so every caller gets the same cache settings. Each fetch states its maximum age and the tags that can invalidate it:

// lib/products.ts
import 'server-only'

export type Product = { id: string; slug: string; name: string; price: number; stock: number }

const API = process.env.CATALOG_API_URL!

export async function getProduct(slug: string): Promise<Product | null> {
  const res = await fetch(`${API}/products/${slug}`, {
    next: {
      revalidate: 300, // at most 5 minutes old
      tags: ['products', `product:${slug}`],
    },
  })

  if (res.status === 404) return null
  if (!res.ok) throw new Error(`Catalog API failed: ${res.status}`)

  return res.json()
}

export async function getProductSlugs(): Promise<string[]> {
  const res = await fetch(`${API}/products?fields=slug&limit=500`, {
    cache: 'force-cache',
    next: { tags: ['products'] },
  })

  return (await res.json()).map((p: { slug: string }) => p.slug)
}

next.revalidate: 300 means the response may be up to five minutes old. After that, the next request still gets the cached value while Next.js refetches in the background: classic stale-while-revalidate. Tags are free-form strings; a broad tag (products) plus a precise one (product:${slug}) lets you invalidate one item or the whole catalog.

For data that does not come from fetch, such as an ORM call, wrap the function with unstable_cache. Despite the name, it is the documented way to cache non-fetch work in this model:

// lib/categories.ts
import 'server-only'
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'

export const getCategoryTree = unstable_cache(
  async (locale: string) => db.category.findMany({ where: { locale }, orderBy: { position: 'asc' } }),
  ['category-tree'], // key prefix; the locale argument is part of the key too
  { revalidate: 3600, tags: ['categories'] },
)

ISR: Static Pages That Stay Fresh

Incremental Static Regeneration is just a static route with a revalidation time. Combine generateStaticParams to prebuild the hot pages, dynamicParams to render the long tail on first request, and a segment revalidate:

// app/products/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getProduct, getProductSlugs } from '@/lib/products'

// ISR: route default is one hour. getProduct() asks for 300 seconds, and the
// lowest value wins, so this route actually regenerates every 5 minutes at most.
export const revalidate = 3600

// Slugs not returned below are rendered on first request, then cached too.
export const dynamicParams = true

export async function generateStaticParams() {
  const slugs = await getProductSlugs()
  return slugs.slice(0, 100).map((slug) => ({ slug })) // prebuild the top 100 only
}

export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const product = await getProduct(slug)

  if (!product) notFound()

  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.price.toLocaleString('en-US')} USD</p>
      <p>{product.stock > 0 ? 'In stock' : 'Sold out'}</p>
    </main>
  )
}

Read the comment on revalidate twice. The lowest revalidation value among the route's layouts, page and fetches decides how often the route regenerates. A single fetch with revalidate: 300 silently turns your "hourly" page into a five-minute page. That is usually fine, but it should be a decision, not a surprise.

When a Route Becomes Dynamic

A route is rendered per request as soon as it uses a request-time API (cookies(), headers(), searchParams, connection()) or a fetch with cache: 'no-store'. That is exactly what you want for personal data:

// app/dashboard/page.tsx
import { cookies } from 'next/headers'

export default async function Dashboard() {
  // Reading cookies is a request-time API: this route now renders per request.
  const session = (await cookies()).get('session')?.value

  const orders = await fetch(`${process.env.ORDERS_API_URL}/me/orders`, {
    headers: { authorization: `Bearer ${session}` },
    cache: 'no-store', // per-user data: never put it in the shared cache
  }).then((r) => r.json())

  return <pre>{JSON.stringify(orders, null, 2)}</pre>
}

The rule that prevents the leak from the intro: never cache a response that depends on who is asking. force-cache caches requests even when they carry authorization or cookie headers, and the cache key is built from the URL, method, headers and body. Different tokens get different keys, so it will not mix users up by accident, but it will happily store personal data in a shared cache where it outlives the session. Use no-store for per-user data and keep cached data public.

You can also force the mode with export const dynamic = 'force-dynamic' or 'force-static'. Prefer letting the APIs you call decide; the forced modes are useful as guard rails (dynamic = 'error' fails the build if a route you expect to be static turns dynamic).

On-Demand Revalidation: updateTag, revalidateTag, revalidatePath

Time-based revalidation is a ceiling. When you know data changed, invalidate it. The three functions behave differently:

  • updateTag(tag) works only in Server Actions. It expires the tagged entries immediately, so the user who just saved sees their own change on the next render. Use it after a mutation in your app.
  • revalidateTag(tag, 'max') marks tagged data stale. The next visitor gets the old content while it regenerates. Use it from webhooks and Route Handlers where nobody is waiting to read their own write. Pass { expire: 0 } instead of 'max' if the data must be gone right away. The single-argument form is deprecated.
  • revalidatePath(path) invalidates a specific route path. For a dynamic segment pattern, add the type: revalidatePath('/products/[slug]', 'page').

The admin action, which needs read-your-own-writes:

// app/admin/products/actions.ts
'use server'

import { revalidatePath, updateTag } from 'next/cache'
import { requireAdmin } from '@/lib/auth'
import { catalog } from '@/lib/catalog-client'

export async function updatePrice(slug: string, price: number) {
  await requireAdmin()
  await catalog.updatePrice(slug, price)

  // The admin who clicked Save must see the new price on the next render:
  // updateTag expires the entry immediately (read-your-own-writes).
  updateTag(`product:${slug}`)

  // The listing page has its own cached HTML; refresh that path too.
  revalidatePath('/products')
}

The CMS webhook, which only needs eventual freshness:

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import type { NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  if (request.headers.get('x-webhook-secret') !== process.env.CATALOG_WEBHOOK_SECRET) {
    return Response.json({ ok: false }, { status: 401 })
  }

  const { slug } = (await request.json()) as { slug?: string }
  if (!slug) return Response.json({ ok: false, error: 'slug required' }, { status: 400 })

  // Stale-while-revalidate: visitors keep getting the old page while it regenerates.
  revalidateTag(`product:${slug}`, 'max')

  return Response.json({ ok: true, revalidated: `product:${slug}` })
}

Testing Cache Invalidation

You cannot meaningfully unit-test Next.js's cache itself, but you can test the part that breaks in practice: whether your actions invalidate the right tags, in the right order, and only after a successful write. Mock next/cache with Vitest:

// app/admin/products/actions.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('next/cache', () => ({ updateTag: vi.fn(), revalidatePath: vi.fn(), revalidateTag: vi.fn() }))
vi.mock('@/lib/auth', () => ({ requireAdmin: vi.fn().mockResolvedValue({ id: 'u1' }) }))
vi.mock('@/lib/catalog-client', () => ({ catalog: { updatePrice: vi.fn().mockResolvedValue(undefined) } }))

import { revalidatePath, updateTag } from 'next/cache'
import { catalog } from '@/lib/catalog-client'
import { updatePrice } from './actions'

describe('updatePrice', () => {
  beforeEach(() => vi.clearAllMocks())

  it('writes first, then expires the product tag and the listing path', async () => {
    await updatePrice('blue-mug', 12)

    expect(catalog.updatePrice).toHaveBeenCalledWith('blue-mug', 12)
    expect(updateTag).toHaveBeenCalledWith('product:blue-mug')
    expect(revalidatePath).toHaveBeenCalledWith('/products')
  })

  it('does not touch the cache when the write fails', async () => {
    vi.mocked(catalog.updatePrice).mockRejectedValueOnce(new Error('API down'))

    await expect(updatePrice('blue-mug', 12)).rejects.toThrow('API down')
    expect(updateTag).not.toHaveBeenCalled()
  })
})

Then verify the build output and the response headers against a production build, because development mode always renders on demand:

next build
# The route table marks each route:
#   ○ Static   ● SSG (prerendered via generateStaticParams)   ƒ Dynamic
# If /products/[slug] shows ƒ, something in it reads cookies, headers or no-store data.

next start &
curl -sI http://localhost:3000/products/blue-mug | grep -i x-nextjs-cache
# x-nextjs-cache: HIT   (MISS on the first visit, STALE while regenerating)

Common Pitfalls

  • Testing caching in next dev. Pages are always rendered on demand in development. Check behaviour with next build && next start.
  • Expecting fetch to be cached by default. It is not since Next.js 15. Without force-cache or revalidate, a fetch is fetched at build time for a static route and on every request for a dynamic one.
  • Revalidating a tag nobody set. revalidateTag('product-1') does nothing if the fetch used product:1. Build tag strings in one helper and reuse it on both sides.
  • Conflicting fetch options. { cache: 'no-store', next: { revalidate: 60 } } is invalid; both are ignored with a warning in development.
  • Calling revalidateTag in a Server Action and wondering why the admin sees old data. With 'max', the first read after invalidation is stale by design. Use updateTag there.
  • Unprotected revalidation endpoints. Anyone who can hit /api/revalidate can force regeneration of your whole catalog. Check a secret or signature.

When to Use What, and When Not To

Use time-based revalidate for content where a few minutes of staleness is acceptable and you have no change events: exchange rates, public stats, third-party feeds.

Use ISR with tags for catalog, blog and docs pages: static speed, with webhooks for freshness.

Use updateTag in Server Actions when a user edits something and expects to see it immediately.

Use dynamic rendering for anything personal, permission-dependent or truly real-time. Do not try to cache your way around it with clever keys; the cost of a leak is far higher than the cost of a render.

If you are starting a new Next.js 16 app, look at Cache Components too: 'use cache' with cacheLife and cacheTag replaces most segment config, and dynamic / revalidate exports do not apply there. The invalidation ideas in this tip carry over unchanged.

Interactive challenge

Try the challenge

Practice what you just learned. Write your solution, reveal hints if you get stuck.

Instructions

Implement RevalidatingCache, a tiny model of a tag-aware stale-while-revalidate data cache. The constructor takes a clock function returning seconds. get(key, loader, options) (JavaScript options object { revalidate = null, tags = [] }; Python keyword args revalidate=None, tags=()) returns { value, status } in JavaScript and a (value, status) tuple in Python. Rules: if the key has no entry, call loader(), store the value with the current clock time, the revalidate seconds and the tags, and return status 'MISS'. If the entry exists, is not marked stale, and either revalidate is null/None or less than revalidate seconds have passed since it was stored, return the cached value with 'HIT' (do not call loader). Otherwise return the OLD value with 'STALE', and replace the entry's value with loader()'s result, stored at the current time and no longer stale (the revalidate and tags of the original entry are kept). revalidateTag(tag) / revalidate_tag(tag) marks every entry carrying the tag as stale and returns how many were marked. expireTag(tag) / expire_tag(tag) deletes every entry carrying the tag and returns how many were deleted, so the next get is a MISS.

Starter code

javascript
class RevalidatingCache {
  constructor(clock) {
    this.clock = clock
    this.entries = new Map()
  }

  get(key, loader, { revalidate = null, tags = [] } = {}) {
    // TODO: MISS  -> no entry: call loader(), store it with clock() time, return { value, status: 'MISS' }
    // TODO: HIT   -> entry not marked stale and not older than revalidate seconds: return cached value
    // TODO: STALE -> otherwise return the OLD value with status 'STALE' and store loader()'s new value
    throw new Error('TODO')
  }

  revalidateTag(tag) {
    // TODO: mark every entry carrying this tag as stale, return how many were marked
    throw new Error('TODO')
  }

  expireTag(tag) {
    // TODO: delete every entry carrying this tag, return how many were deleted
    throw new Error('TODO')
  }
}
python
from typing import Any, Callable


class RevalidatingCache:
    def __init__(self, clock: Callable[[], float]) -> None:
        self.clock = clock
        self.entries: dict[str, dict[str, Any]] = {}

    def get(
        self,
        key: str,
        loader: Callable[[], Any],
        revalidate: float | None = None,
        tags: tuple[str, ...] | list[str] = (),
    ) -> tuple[Any, str]:
        # TODO: MISS  -> no entry: call loader(), store it with clock() time, return (value, "MISS")
        # TODO: HIT   -> entry not stale and not older than revalidate seconds: return cached value
        # TODO: STALE -> otherwise return the OLD value with "STALE" and store loader()'s new value
        raise NotImplementedError

    def revalidate_tag(self, tag: str) -> int:
        # TODO: mark entries with this tag as stale, return how many
        raise NotImplementedError

    def expire_tag(self, tag: str) -> int:
        # TODO: delete entries with this tag, return how many
        raise NotImplementedError

Your solution

Hints

All hints revealed
Hint 1

Store one record per key: value, storedAt, revalidate, tags and a stale flag

Hint 2

Expired means revalidate is set and now - storedAt >= revalidate; stale flag or expired both lead to STALE

Hint 3

On STALE keep a reference to the old value before overwriting the entry with the loader result

Solution · javascript
class RevalidatingCache {
  constructor(clock) {
    this.clock = clock
    this.entries = new Map()
  }

  get(key, loader, { revalidate = null, tags = [] } = {}) {
    const now = this.clock()
    const entry = this.entries.get(key)

    if (!entry) {
      const value = loader()
      this.entries.set(key, { value, storedAt: now, revalidate, tags: [...tags], stale: false })
      return { value, status: 'MISS' }
    }

    const expired = entry.revalidate !== null && now - entry.storedAt >= entry.revalidate
    if (!entry.stale && !expired) {
      return { value: entry.value, status: 'HIT' }
    }

    const old = entry.value
    entry.value = loader()
    entry.storedAt = now
    entry.stale = false
    return { value: old, status: 'STALE' }
  }

  revalidateTag(tag) {
    let count = 0
    for (const entry of this.entries.values()) {
      if (entry.tags.includes(tag)) {
        entry.stale = true
        count++
      }
    }
    return count
  }

  expireTag(tag) {
    let count = 0
    for (const [key, entry] of this.entries) {
      if (entry.tags.includes(tag)) {
        this.entries.delete(key)
        count++
      }
    }
    return count
  }
}
Solution · python
from typing import Any, Callable


class RevalidatingCache:
    def __init__(self, clock: Callable[[], float]) -> None:
        self.clock = clock
        self.entries: dict[str, dict[str, Any]] = {}

    def get(
        self,
        key: str,
        loader: Callable[[], Any],
        revalidate: float | None = None,
        tags: tuple[str, ...] | list[str] = (),
    ) -> tuple[Any, str]:
        now = self.clock()
        entry = self.entries.get(key)

        if entry is None:
            value = loader()
            self.entries[key] = {"value": value, "stored_at": now, "revalidate": revalidate, "tags": list(tags), "stale": False}
            return value, "MISS"

        expired = entry["revalidate"] is not None and now - entry["stored_at"] >= entry["revalidate"]
        if not entry["stale"] and not expired:
            return entry["value"], "HIT"

        old = entry["value"]
        entry.update(value=loader(), stored_at=now, stale=False)
        return old, "STALE"

    def revalidate_tag(self, tag: str) -> int:
        count = 0
        for entry in self.entries.values():
            if tag in entry["tags"]:
                entry["stale"] = True
                count += 1
        return count

    def expire_tag(self, tag: str) -> int:
        keys = [k for k, e in self.entries.items() if tag in e["tags"]]
        for k in keys:
            del self.entries[k]
        return len(keys)

More from Next.js