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.

May 5, 2025/12 min readArchitecture
ShareY
Caching in the App Router Without the Footguns

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

CacheScopeCleared by
Request memoizationOne render passAutomatic — dedupes identical fetch in a render
Data CacheAcross requests, persistentrevalidateTag, revalidatePath, time-based revalidate
Full Route CacheBuilt routes, persistentA new deploy, or revalidating the data a route depends on
Router Cache (client)The user’s session, in memoryrouter.refresh(), a full reload, time
Note

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.

lib/data/content.ts
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.

caching a database query
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.

app/invoices/actions.ts
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.

belt and braces after a mutation
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-store on the server, or fetch on the client with a cache library.
  • After any mutation: revalidateTag for the data, router.refresh() on the client, and test the back button.

Found this useful? Pass it on.

All posts

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.