Caching and Revalidation
Why fetch isn't cached by default, and the three ways to control how long data sticks around.
3 menit membaca
A natural assumption coming from other frameworks is that Next.js caches everything aggressively by default. In current versions, it's the opposite: a plain fetch call with no options is not cached — every request re-runs it. Caching is something you opt into deliberately, which makes it worth understanding exactly what each option controls.
Opting a fetch call into caching
Pass cache: "force-cache" to cache a request's result indefinitely, reusing it across requests until you explicitly invalidate it:
const res = await fetch("https://api.example.com/products", {
cache: "force-cache",
});For content that should refresh automatically after some time rather than staying cached forever, use next.revalidate instead — this is time-based revalidation:
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 3600 }, // refresh at most once per hour
});The first request after the hour has passed still returns the (now stale) cached data, but Next.js kicks off a fresh fetch in the background and swaps it in for subsequent visitors — a pattern called stale-while-revalidate.
Caching non-fetch data with unstable_cache
Database queries and other non-fetch async work don't get automatic caching options the way fetch does. unstable_cache wraps any async function with the same time-based and tag-based controls:
// lib/data.ts
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";
export const getCachedProducts = unstable_cache(
async () => db.select().from(products),
["products"], // cache key
{ revalidate: 3600, tags: ["products"] }
);Despite the name, unstable_cache is stable to use in production — the "unstable" prefix signals that its API shape may still evolve, not that it's unreliable.
On-demand revalidation with tags
Time-based revalidation is convenient, but sometimes you know exactly when data changed — right after a mutation, for example — and don't want to wait for a timer. Tag a request when you fetch it, then invalidate everything with that tag from a Server Action or Route Handler:
// Tagging a fetch
const res = await fetch("https://api.example.com/products", {
next: { tags: ["products"] },
});// app/actions.ts
"use server";
import { revalidateTag } from "next/cache";
export async function updateProduct(id: string, data: FormData) {
await saveProduct(id, data);
revalidateTag("products"); // every fetch tagged "products" refreshes
}revalidatePath("/products") is the coarser alternative — it invalidates everything cached for a specific route rather than everything under a tag, which is simpler when you don't need tag-level precision.
Route segment config
You can also control caching for an entire page or layout at once by exporting configuration from the file:
// app/dashboard/page.tsx
export const dynamic = "force-dynamic"; // always render fresh, per requestdynamic accepts "auto" (the default — cache what it safely can), "force-dynamic" (never cache, always render per-request — the right choice for a page showing per-user data like a dashboard), "force-static", and "error" (fail the build if anything on the page can't be statically rendered).
The default is safety, not speed
It's worth sitting with why fetch defaults to uncached: stale data silently served to users is a much harder bug to notice than a slightly slower page. Reach for force-cache and revalidate deliberately, for data you've actually thought about — a product catalog, a blog's list of posts — rather than assuming caching is "free" and applying it everywhere.