The first Next.js App Router site I shipped for a client showed yesterday's prices for six hours after they updated the CMS. The second one re-rendered every page on every request and cost four times what it should have. Both were caching mistakes, and both came from not having a clear picture of which cache was doing what. This is that picture.
The four caches, in one diagram
Most confusion comes from mixing up 2 and 3. The data cache stores the results of individual fetches. The full route cache stores the rendered output of an entire static route. A route can be dynamic (no route cache) while its fetches are still served from the data cache, which is the combination you usually want for personalised pages that show shared content.
What the defaults are now
Next.js 14 cached aggressively by default and surprised everyone. Next.js 15 flipped it: fetch defaults to no-store, GET route handlers are dynamic, and the client router cache no longer reuses page segments for dynamic routes. That means you now opt into caching explicitly, which is less magical and, in my experience, far easier to reason about.
// Not cached (default in Next 15+): hits the API on every request
const live = await fetch('https://api.example.com/prices');
// Cached in the data cache indefinitely, until revalidated
const cached = await fetch('https://api.example.com/catalog', { cache: 'force-cache' });
// Cached, and automatically revalidated in the background after 5 minutes (ISR-style)
const fresh = await fetch('https://api.example.com/posts', { next: { revalidate: 300 } });
// Cached with tags so you can purge precisely from a webhook
const post = await fetch(`https://cms.example.com/posts/${id}`, { next: { tags: ['posts', `post-${id}`] } });
Caching database calls and SDKs
fetch options do nothing for a Postgres query or a Stripe SDK call. For those, wrap the function with unstable_cache (or the "use cache" directive if you have enabled dynamicIO):
// lib/queries.ts
import { unstable_cache } from 'next/cache';
import { sql } from '@/lib/db';
export const getPublishedPosts = unstable_cache(
async (limit: number) => sql`SELECT id, slug, title, excerpt FROM posts WHERE status = 'published' ORDER BY published_at DESC LIMIT ${limit}`,
['published-posts'], // part of the cache key
{ tags: ['posts'], revalidate: 3600 } // tag for purging, 1h safety net
);
export const getPost = unstable_cache(
async (slug: string) => (await sql`SELECT * FROM posts WHERE slug = ${slug}`)[0] ?? null,
['post'],
{ tags: ['posts'] } // per-post tag added below
);
One gotcha: arguments become part of the key automatically, but tags do not vary by argument in that call. For per-post tags, generate the wrapper per slug:
export function getPostCached(slug: string) {
return unstable_cache(
() => getPostUncached(slug),
['post', slug],
{ tags: ['posts', `post-${slug}`] }
)();
}
Invalidation: tags beat timers
A one-hour revalidate means content is stale for up to an hour. That was the six-hour bug (there were three nested layers each set to two hours). The fix is on-demand revalidation: the CMS calls a webhook the moment something changes, and you purge exactly the tags affected.
// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
import { timingSafeEqual } from 'node:crypto';
export async function POST(req: NextRequest) {
const sig = req.headers.get('x-webhook-secret') ?? '';
const expected = process.env.REVALIDATE_SECRET ?? '';
if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return NextResponse.json({ ok: false }, { status: 401 });
}
const { type, slug } = await req.json(); // sent by the CMS (WordPress hook, Sanity, etc.)
if (type === 'post' && slug) {
revalidateTag(`post-${slug}`); // the single post
revalidateTag('posts'); // lists that include it
revalidatePath('/sitemap.xml');
}
return NextResponse.json({ ok: true, revalidated: Date.now() });
}
On the WordPress side this is a save_post hook doing one wp_remote_post(); the full setup, including preview mode, is in my headless WordPress architecture guide. The measured update latency on that site went from "up to two hours" to about 300 ms.
What makes a route dynamic (and how to contain it)
Any of these in a page or a component it renders opts the whole route out of the full route cache:
cookies(),headers(),searchParams(awaited in Next 15+)- An uncached
fetch(the default), orconnection()/noStore() export const dynamic = 'force-dynamic'
The trap: one tiny "Hello, {user}" in the header makes every page dynamic. The fix is to keep the dynamic read in a small component and wrap it in Suspense, so the static shell is cached and only the greeting streams in:
// components/UserGreeting.tsx (server, dynamic)
import { cookies } from 'next/headers';
export async function UserGreeting() {
const name = (await cookies()).get('display_name')?.value;
return <span>{name ? `Hi, ${name}` : 'Sign in'}</span>;
}
// app/layout.tsx (static shell, streams the dynamic hole)
import { Suspense } from 'react';
export default function RootLayout({ children }) {
return (
<html><body>
<header>
<Logo />
<Suspense fallback={<span>…</span>}><UserGreeting /></Suspense>
</header>
{children}
</body></html>
);
}
With Partial Prerendering enabled this shell is served from the edge as static HTML and the greeting streams from the server; without it you still get a static route with a dynamic hole streamed in. Either way, the product pages beneath stay in the full route cache.
Decision table
| Content | Strategy | Code |
|---|---|---|
| Marketing pages, docs | Static, rebuild on deploy | Default with cached fetches; generateStaticParams |
| Blog / CMS content | Static + on-demand revalidation | tags + webhook → revalidateTag; revalidate: 86400 as a safety net |
| Product catalogue with prices | Static shell, short-TTL price fetch | Page cached; prices via revalidate: 60 or a client island |
| Personalised dashboard | Dynamic route, cached data | cookies() in page; shared data via unstable_cache with tags |
| Search results | Dynamic, no cache (or short) | Reads searchParams; cache the expensive part (e.g. query embedding) in Redis |
| Real-time data | No server cache | cache: 'no-store' + client polling or streaming |
Debugging in three commands
next buildprints a route table: ○ means static (full route cache), ƒ means dynamic. If a route you expected to be static is ƒ, something above is reading request data or doing an uncached fetch.- Set
logging: { fetches: { fullUrl: true } }innext.config.jsand dev logs will show each fetch as(cache: HIT)or(cache: MISS). - Check response headers in production:
x-nextjs-cache: HIT | MISS | STALEon the page, and on Vercelx-vercel-cachefor the CDN layer.
After a Server Action, call revalidatePath or revalidateTag inside the action; it clears the server caches and the client router cache for that path in one go. Calling router.refresh() from the client is the fallback when the mutation happened elsewhere (another tab, a webhook). If a user says "I saved but the list didn't update", it is almost always this.
Once these four layers are in your head, the App Router stops feeling unpredictable and starts feeling like a CDN you control from code. Pair it with the Server Components mental model and you have the whole rendering story. For the layer below Next.js, when you own the server, see Redis caching strategies and Nginx tuning.