Post 04
Caching in the App Router Without the Footguns
Next.js caching has a reputation, most of it earned. But the model is learnable, and once you can name the four caches you stop being surprised by stale data. Here is the mental model plus the revalidation patterns I trust in production.

The complaint about Next.js caching is usually “my data is stale and I don’t know why.” That is almost always because there are several caches and it is not obvious which one is holding the old value. Name them and the mystery mostly goes away.
The caches, from request to response
| Cache | Scope | Cleared by |
|---|---|---|
| Request memoization | One render pass | Automatic — dedupes identical fetch in a render |
| Data Cache | Across requests, persistent | revalidateTag, revalidatePath, time-based revalidate |
| Full Route Cache | Built routes, persistent | A new deploy, or revalidating the data a route depends on |
| Router Cache (client) | The user’s session, in memory | router.refresh(), a full reload, time |
When data looks stale: if it is stale for everyone, it is the Data Cache or the Full Route Cache. If it is stale only for the user who just made a change, it is the client Router Cache.
Be explicit about fetch caching
Recent Next.js versions default fetch to uncached, which is safer but means you opt into caching deliberately. Do that per call, based on how the data actually behaves.
1// changes rarely — cache and revalidate on a timer2export const getMarketingPage = (slug: string) =>3 fetch(`${API}/pages/${slug}`, { next: { revalidate: 3600 } })4 .then((r) => r.json());5 6// changes on user action — cache, tag it so a mutation can bust it7export const getInvoices = (orgId: string) =>8 fetch(`${API}/orgs/${orgId}/invoices`, {9 next: { tags: [`invoices:${orgId}`] },10 }).then((r) => r.json());11 12// must be fresh every time13export const getLiveSeats = (eventId: string) =>14 fetch(`${API}/events/${eventId}/seats`, { cache: "no-store" })15 .then((r) => r.json());For non-fetch data access — a database query, an SDK call — wrap it in unstable_cache (or the current stable equivalent) with the same tag semantics, or it will not be cached at all.
1import { unstable_cache } from "next/cache";2 3export const getPricingTiers = unstable_cache(4 async () => db.tier.findMany({ orderBy: { order: "asc" } }),5 ["pricing-tiers"], // cache key parts6 { tags: ["pricing"], revalidate: 86_400 },7);Tag-based revalidation is the pattern to reach for
Time-based revalidation is fine for content that has no clear “changed now” moment. For anything a user edits, tag the reads and bust the tag in the write.
1"use server";2import { revalidateTag } from "next/cache";3 4export async function createInvoice(orgId: string, data: InvoiceInput) {5 await db.invoice.create({ data: { ...data, orgId } });6 // scope the tag as tightly as the data — one org's write7 // does not blow away every org's cache8 revalidateTag(`invoices:${orgId}`);9}10 11export async function voidInvoice(orgId: string, id: string) {12 await db.invoice.update({ where: { id }, data: { status: "void" } });13 revalidateTag(`invoices:${orgId}`);14}The client Router Cache is the one that surprises people
After a Server Action mutates data and calls revalidateTag, the server data is fresh — but the user’s client-side Router Cache may still serve the old rendered result on back/forward navigation.
1"use client";2import { useRouter } from "next/navigation";3import { useTransition } from "react";4import { createInvoice } from "./actions";5 6export function NewInvoiceButton({ orgId }: { orgId: string }) {7 const router = useRouter();8 const [pending, start] = useTransition();9 10 return (11 <button12 disabled={pending}13 onClick={() =>14 start(async () => {15 await createInvoice(orgId, draft);16 router.refresh(); // clears the client Router Cache for the route17 })18 }19 >20 {pending ? "Creating…" : "New invoice"}21 </button>22 );23}Stale data is almost never a bug in the cache. It is a mismatch between how long you told Next.js the data stays valid and how long it actually does.
A workable default policy
- Marketing and content pages: time-based
revalidate, measured in minutes to hours. - Authenticated app data: tag every read, bust tags in every write, no time-based revalidation.
- Truly live data (prices, presence, seats):
no-storeon the server, or fetch on the client with a cache library. - After any mutation:
revalidateTagfor the data,router.refresh()on the client, and test the back button.
Found this useful? Pass it on.
Want a custom write-up for your team? Get in touch.
Building something like this?
If a post here maps to a problem on your roadmap, that's usually a good sign we should talk.