Practical Caching Recipes for Next.js (App Router)

Caching in the App Router is simple once you know the few primitives that matter. This guide gives copy‑pasteable snippets you can drop into a fresh Next.js app and get real wins today.
We’ll cover:
- Segment‑level caching (static vs dynamic)
fetch
caching and ISR- Tag‑based revalidation (
revalidateTag
) - Route Handler cache headers
- Server Actions + revalidation
- Client‑side SWR for UX polish
1) Segment‑level caching: the baseline
Control how a route renders by default. Add these exports at the top of a page/layout.
// app/products/page.tsx
export const revalidate = 60; // ISR: re-render at most every 60s
// export const dynamic = 'force-static'; // full static (no per-request data)
// export const dynamic = 'force-dynamic'; // fully dynamic (no caching)
export default async function ProductsPage() {
const products = await getTopProducts();
return <List items={products} />;
}
Tips:
- Prefer
revalidate
for content that changes occasionally. - Use
force-dynamic
for user‑specific views or rapidly changing data.
2) fetch
caching: per‑request control
fetch
grows superpowers in the App Router: you can set ISR per request and attach cache tags.
// app/products/page.tsx
export default async function ProductsPage() {
const products = await fetch('https://api.example.com/products', {
// Revalidate cached response every 60s
next: { revalidate: 60, tags: ['products'] },
// cache: 'force-cache' // default when revalidate provided
}).then(r => r.json());
return <List items={products} />;
}
Notes:
next: { revalidate }
enables ISR even for dynamic data sources.- Add
tags
to enable precise invalidation later.
3) Tag‑based revalidation (webhooks or mutations)
Invalidate cached content tied to a tag from any server context.
// app/api/revalidate/products/route.ts
import { revalidateTag } from 'next/cache';
export async function POST() {
revalidateTag('products'); // blow away caches tagged "products"
return new Response('ok');
}
Pair with an upstream webhook (e.g., CMS → POST to /api/revalidate/products
).
Server Action variant:
// app/(admin)/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function createProduct(formData: FormData) {
// ... persist product ...
revalidateTag('products'); // refresh list views
}
4) Route Handlers with explicit HTTP cache headers
Use HTTP headers for fine‑grained control (and CDNs love these).
// app/api/search/route.ts
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const q = searchParams.get('q') ?? '';
const data = await search(q);
// Cache popular empty query; bypass cache for custom queries
const headers = new Headers({ 'content-type': 'application/json' });
if (q === '') headers.set('cache-control', 's-maxage=60, stale-while-revalidate=600');
else headers.set('cache-control', 'no-store');
return new Response(JSON.stringify(data), { headers });
}
Also handy:
fetch(url, { next: { revalidate } })
inside Route Handlersexport const runtime = 'edge'
to move handler to the Edge
5) Mutation → revalidate (happy users, fresh pages)
After a write, refresh the right pages or data.
// app/(admin)/products/actions.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function updatePrice(id: string, price: number) {
await savePrice(id, price);
revalidateTag('products'); // invalidate list caches
revalidatePath('/products'); // and the products page route
}
Pick one or combine both depending on what you cache.
6) Client‑side polish with SWR
Even with ISR, client hints make the UI feel instant.
// app/products/_components/Price.tsx
'use client';
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then(r => r.json());
export function Price({ id }: { id: string }) {
const { data } = useSWR(`/api/price?id=${id}`, fetcher, {
refreshInterval: 30_000, // keep it fresh
});
if (!data) return <span>$—</span>;
return <span>${data.price}</span>;
}
Pair this with a cached /api/price
Route Handler.
7) Prefetching and “stale‑while‑revalidate” mental model
- Links prefetch by default in viewport; keep your pages cacheable to benefit.
- Think: “serve old data fast, revalidate in background, then refresh.” That’s ISR in practice.
Mermaid mental model:
sequenceDiagram
participant U as User
participant C as Cache
participant S as Server
U->>C: Request /products
alt cached and fresh
C-->>U: Serve cached HTML/data
else cached but stale
C-->>U: Serve stale data fast
C->>S: Trigger revalidation
S-->>C: Store fresh result
end

8) Edge vs Node: tiny knobs
// app/api/geo/route.ts
export const runtime = 'edge'; // ultra low‑latency
export async function GET() {
return Response.json({ ok: true }, {
headers: { 'cache-control': 's-maxage=30, stale-while-revalidate=300' },
});
}
Edge is great for fast, cacheable reads. Keep heavy compute and big SDKs on Node runtime.
9) Common pitfalls (and quick fixes)
- User‑specific pages: mark as dynamic (
force-dynamic
) or setcache: 'no-store'
onfetch
. - Mixed models: if you add
cookies()
/headers()
in a segment, it becomes dynamic. Move stateful logic to a child or Route Handler. - Tag mismatch:
revalidateTag('x')
only purgesfetch(..., { next: { tags: ['x'] } })
calls. - Static assets:
next/image
is already optimized; avoid manual cache busting unless you must.
10) Quick starter you can paste
// app/blog/page.tsx
export const revalidate = 120; // 2 minutes ISR
export default async function Blog() {
const posts = await fetch('https://api.example.com/blog', {
next: { revalidate: 120, tags: ['blog'] },
}).then(r => r.json());
return (
<ul>
{posts.map((p: any) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
// app/api/revalidate/blog/route.ts
import { revalidateTag } from 'next/cache';
export async function POST() {
revalidateTag('blog');
return new Response('ok');
}
That’s it—drop in, ship, and enjoy fast pages that stay fresh.