Next.js caching cheatsheet.
The four caches
- Request memoization (per request, dedupe).
- Data cache (server, persistent, between requests).
- Full route cache (server, persistent, full HTML/RSC payload).
- Router cache (client, in-memory, for back/forward navigation).
Request memoization
Within a single request, identical fetch calls hit cache:
async function User() {
const u = await fetch("/api/user").then(r => r.json()); // hits network
}
async function Header() {
const u = await fetch("/api/user").then(r => r.json()); // memoized
}
Use cache() for non-fetch:
import { cache } from "react";
export const getUser = cache(async (id: number) => db.user.find(id));
Data cache (fetch options)
fetch(url, { cache: "force-cache" }); // cache forever
fetch(url, { cache: "no-store" }); // never cache
fetch(url, { next: { revalidate: 60 } }); // ISR: 60s
fetch(url, { next: { tags: ["users"] } }); // tag-based revalidate
Full route cache
Static routes (no dynamic functions) are cached as the rendered RSC payload + HTML. Invalidated by:
revalidatePath()revalidateTag()revalidatetime- New deploy
Dynamic routes (using cookies, headers, params with dynamic = "force-dynamic") skip this.
Router cache (client)
When you <Link> to a route, the response is cached in memory for 30s (dynamic) or 5min (static, before Next 15 default). Back/forward use cache.
Invalidate:
import { useRouter } from "next/navigation";
const router = useRouter();
router.refresh(); // re-fetches RSC for current route
Triggering revalidation
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function updateUser(id: number, data: any) {
await db.user.update({ where: { id }, data });
revalidatePath(`/users/${id}`);
revalidateTag("users");
}
Opting out
Per-route:
export const dynamic = "force-dynamic";
Per-fetch:
fetch(url, { cache: "no-store" });
Per-request (via dynamic function use):
import { cookies } from "next/headers";
await cookies(); // route becomes dynamic
ISR with on-demand revalidation
export const revalidate = 3600; // 1 hour
// API route to trigger revalidate
export async function POST(req: Request) {
const { secret, path } = await req.json();
if (secret !== process.env.REVAL_SECRET) {
return NextResponse.json({ error: "bad" }, { status: 401 });
}
revalidatePath(path);
return NextResponse.json({ revalidated: true });
}
Webhook from CMS → revalidate page.
unstable_cache (persistent non-fetch)
import { unstable_cache } from "next/cache";
export const getTopPosts = unstable_cache(
async () => db.post.findMany({ orderBy: { views: "desc" }, take: 10 }),
["top-posts"],
{ revalidate: 300, tags: ["posts"] },
);
For DB queries that should persist between requests like fetch.
Cache invalidation
revalidatePath("/posts"); // exact path
revalidatePath("/posts", "page"); // also re-invalidate the path's layout
revalidatePath("/[id]", "layout"); // entire layout subtree
revalidateTag("posts"); // any cache entry with this tag
staleTime / nextRevalidate per page
// Static page with auto-revalidate
export const revalidate = 60;
Compare with dynamic = "force-dynamic" — total skip of caching.
Cache hit/miss in dev
NEXT_PRIVATE_DEBUG_CACHE=1 next dev
Logs each cache decision.
Common patterns
Static blog: export const revalidate = 3600; per post. CMS webhook calls revalidatePath on publish.
User dashboard: dynamic = "force-dynamic"; everywhere, or use cookies() to auto-trigger.
Public API proxy: tag responses, revalidate on backend events.
Common mistakes
force-cacheon per-user data — returns wrong user’s data.- Forgetting
revalidate*after mutation — UI stays stale. - Heavy unstable_cache without bounds → cache grows.
- Mixing
revalidateanddynamic = "force-dynamic"— contradictory. - Cache layer skipped because of cookies() but not realizing.
Read this next
If you want my caching patterns + webhook revalidator, they’re at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .