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
GETfetches during one render are executed once. It lasts for a single render pass and needs no configuration. - Data Cache. The persistent cache for
fetchresults (andunstable_cacheresults). It survives across requests and deployments on platforms that persist it. You opt in withcache: 'force-cache'ornext.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.
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 withnext build && next start. - Expecting fetch to be cached by default. It is not since Next.js 15. Without
force-cacheorrevalidate, 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 usedproduct: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
revalidateTagin a Server Action and wondering why the admin sees old data. With'max', the first read after invalidation is stale by design. UseupdateTagthere. - Unprotected revalidation endpoints. Anyone who can hit
/api/revalidatecan 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.
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
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')
}
}
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 revealedStore one record per key: value, storedAt, revalidate, tags and a stale flag
Expired means revalidate is set and now - storedAt >= revalidate; stale flag or expired both lead to STALE
On STALE keep a reference to the old value before overwriting the entry with the loader result
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
}
}
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)

