What you'll learn
By the end of this you'll understand what caching means in the App Router, how to tell Next.js to refresh a page every N seconds, and when to reach for revalidatePath or revalidateTag to bust the cache on demand. You'll also know the one mistake that silently breaks builds and leaves no helpful error message.
This isn't a deep dive into every layer of the Next.js cache internals. It's the practical working knowledge you need to ship pages that stay fresh without hammering your database on every single request.
Who this is for
- You're building with the App Router and confused about why some changes don't show up immediately after deploy
- You've heard of ISR (Incremental Static Regeneration) but aren't sure how that concept maps to the App Router
- You're getting stale data in production and you're not sure where the caching fits in
You can skip this if you already understand the difference between static and dynamic rendering and you just want to look up the exact revalidateTag API. The Next.js docs are faster for pure reference.
What is caching in the App Router?
Caching just means saving a computed result so you don't recalculate it on every request. Simple idea. In Next.js, it mostly means: the server built a page (or fetched some data) once, and it serves that same result to everyone until the cache expires or is manually cleared.
Plain English version: your page is like a baked loaf of bread — caching means you slice and serve that same loaf to everyone. Revalidation means you bake a new loaf.
The App Router does a lot of caching automatically. That's mostly great for performance and occasionally confusing when you actually need fresh data.
Prerequisites
- You're comfortable with the App Router (layouts, pages, Server Components)
- You know that Server Components run on the server and can fetch data directly
- Basic TypeScript — nothing beyond reading function signatures
If the App Router is still unfamiliar, start with the App Router basics guide. The caching model makes a lot more sense once you understand Server Components and how rendering works.
Setup from zero
We'll build a small page that shows the caching behavior you actually care about.
Step 1 — Create a page that fetches data
Make src/app/posts/page.tsx:
async function getPosts() {
const res = await fetch(
"https://jsonplaceholder.typicode.com/posts?_limit=5"
);
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<main>
<h1>Posts</h1>
<ul>
{posts.map((p: { id: number; title: string }) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
</main>
);
}
By default, fetch in a Server Component is cached indefinitely — if you run next build, this response is baked in at build time.
Step 2 — Add revalidation
Add one export above the component:
export const revalidate = 60;
Now the page will regenerate at most once every 60 seconds when it gets traffic. Existing visitors still get the cached version instantly; the first request after the window expires triggers a background rebuild. Everyone keeps getting responses fast.
Step 3 — Verify in production mode
Run next build then next start. Check the build output — it should list the /posts route with a revalidate annotation. Reload the page repeatedly in the browser; it should feel instant because it's serving the cache. After 60 seconds, the next reload triggers the background refresh.
Little tip: in development (next dev), caching is largely disabled so you see fresh data on every reload. That's intentional — you want live updates while developing. The revalidate behavior only kicks in with a production build. Always test caching with next build && next start, not the dev server.
Core idea / mental model
The App Router has a two-level mental model for rendering:
- Static — HTML built once at deploy time. Blazing fast. Doesn't change until you redeploy or trigger revalidation.
- Dynamic — HTML built fresh on every request. Always current. You pay a database or network cost on every load.
Most real sites want mostly static pages, with revalidation to stay reasonably fresh. That's what export const revalidate = 60 gives you — the old ISR behavior that content sites have relied on for years, now built into the fetch layer.
Mental model: static by default, dynamic when you need it, revalidation as the middle ground.
And that middle ground is where most production blogs, docs sites, and marketing pages actually live.
Key terms
- Static rendering — page HTML built at build time; same for all users until cache expires
- Dynamic rendering — page HTML built per request; can vary by user, cookies, headers
- revalidate — an integer (seconds) exported from a route segment file; tells Next.js how often to regenerate the page in the background
- revalidatePath — a function from
next/cachethat manually clears the cache for a specific URL path - revalidateTag — a function from
next/cachethat clears the cache for all fetch calls tagged with a specific string - ISR — Incremental Static Regeneration; the Pages Router term for "rebuild this page in the background after N seconds." The App Router does the same via
revalidate - fetch cache — Next.js extends the native
fetchto cache responses; by default in a Server Component,fetchcaches indefinitely
Step-by-step
Controlling fetch behavior per-call
Instead of a page-level revalidate, you can control caching on each individual fetch:
// Cached indefinitely (default for Server Components)
const data = await fetch("https://api.yoursite.com/data");
// Revalidate every 30 seconds
const data = await fetch("https://api.yoursite.com/data", {
next: { revalidate: 30 },
});
// Never cache — always fetch fresh
const data = await fetch("https://api.yoursite.com/data", {
cache: "no-store",
});
Using cache: "no-store" on a fetch opts the entire route into dynamic rendering — Next.js detects it and stops treating the page as static. That's fine when you need it, just know you're paying the cost on every load.
On-demand revalidation with revalidatePath
Time-based revalidation isn't always enough. Sometimes you want to clear the cache the moment something changes — right after a CMS publish, right after a form submission that affects the page.
import { revalidatePath } from "next/cache";
export async function publishPost(slug: string) {
"use server";
await db.posts.publish(slug);
revalidatePath("/blog/" + slug);
revalidatePath("/blog");
}
After this runs, the very next visit to that path gets fresh data. The two revalidatePath calls clear both the post page and the index listing.
On-demand revalidation with revalidateTag
Tags let you group related fetch calls so you can invalidate them all at once:
// In your data-fetching function
const res = await fetch("https://api.yoursite.com/posts", {
next: { tags: ["posts"] },
});
// In a server action or API route
import { revalidateTag } from "next/cache";
revalidateTag("posts");
Little tip: use tags when multiple pages or layouts fetch the same underlying data. One revalidateTag("posts") call clears all of them at once, without you listing every affected path. This is especially useful for CMS webhook handlers.
Working examples
A real blog post page with sensible caching:
import type { Metadata } from "next";
export const revalidate = 3600;
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return { title: post.title, description: post.excerpt };
}
async function getPost(slug: string) {
const res = await fetch(
"https://api.yoursite.com/posts/" + slug,
{ next: { tags: ["post-" + slug, "posts"] } }
);
if (!res.ok) throw new Error("Post not found");
return res.json();
}
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
return (
<article>
<h1>{post.title}</h1>
<div>{post.body}</div>
</article>
);
}
When a post is updated in the CMS, you call revalidateTag("post-" + slug) from a webhook handler. The next visitor gets fresh data, no redeploy needed.
Patterns
Static + revalidate for: blogs, docs, marketing pages, anything that changes infrequently but serves high traffic. Set revalidate to match how often content actually changes — 3600 (hourly) for a blog, 300 (5 minutes) for a news feed.
cache: "no-store" for: dashboards, authenticated pages, anything that must differ per user or per session. Accept the per-request cost — it's the right call here.
Tags + revalidateTag for: CMS-driven content where you want instant updates when an editor publishes. Pair with a webhook from your CMS platform.
revalidatePath for: simpler cases where you know exactly which paths change — after a form submission that affects a specific page, for example.
Common mistakes
Setting revalidate to a variable instead of a literal
// Breaks the build, or silently falls back to dynamic
const REVALIDATE_SECONDS = 60;
export const revalidate = REVALIDATE_SECONDS;
// This works
export const revalidate = 60;
The App Router needs to read revalidate statically at build-time analysis. If it's a variable reference, Next.js can't resolve the value at compile time. In some versions this causes a build error, in others the page silently becomes fully dynamic. Always use a plain number literal — no constants, no expressions.
Testing caching in next dev
Caching is mostly off in development. If you set revalidate = 60 and check your dev server, you'll see fresh data on every reload regardless, because the cache isn't active. Don't try to debug caching in dev mode — run next build && next start to see how it actually behaves.
One cache: "no-store" fetch pulling the whole page into dynamic rendering
A single cache: "no-store" fetch anywhere in the route makes the whole page dynamic. That includes fetches inside child components. Be intentional: if you want a page to be static with one live widget, isolate the dynamic fetch in a dedicated Client Component that loads after hydration.
Wrong path format in revalidatePath
The path must match your actual route, not a pattern. revalidatePath("/blog/[slug]") won't work as you'd expect — use the real path like revalidatePath("/blog/my-post-title"). Check the Next.js docs for the type parameter if you need to clear a whole layout segment.
Troubleshooting
Page isn't updating after setting revalidate: You're probably testing in next dev. Run next build && next start and test there. Also confirm the first request after the revalidate window has actually been made — background rebuilds only trigger when someone visits the page after the timer expires.
revalidateTag not working: Check that the fetch call has next: { tags: ["your-tag"] } and that the string in revalidateTag matches exactly. A typo in one place means the cache never gets cleared.
Build error about revalidate: You've assigned a variable to it. Change it to a number literal.
Page rendering dynamically despite revalidate: Check the build output — Next.js shows whether a route is static, ISR, or dynamic. If it's dynamic, search the route for cookies(), headers(), or cache: "no-store" fetches — any of those force dynamic rendering regardless of your revalidate export.
Checklist
- [ ]
export const revalidate = 60uses a plain number literal (not a variable) - [ ] Caching tested with
next build && next start, notnext dev - [ ] Build output confirms routes are static or ISR as intended
- [ ] Fetches that must be fresh use
cache: "no-store"explicitly - [ ] On-demand revalidation (if used) tested via a server action or API route call
- [ ] No accidental
cookies()orheaders()call forcing dynamic rendering on a page that should be static - [ ] Tags are consistent between the
fetchcalls and therevalidateTagcalls (exact string match)
Practice task
Take a page in your project that fetches data. Add export const revalidate = 300. Run next build. Find that route in the build output and check whether it's labeled as static/ISR or dynamic. If it's still dynamic, use the build output and the list of dynamic-rendering triggers (cookies, headers, no-store fetch) to find what's forcing it. Fix it and rebuild until the route shows as ISR.
FAQ
What's the difference between revalidate in the fetch options vs. the route segment export?
The route segment export const revalidate = 60 sets a default for every fetch in that route. The per-fetch { next: { revalidate: 30 } } overrides it for that specific call. The lower number between the two wins.
Does revalidatePath work inside a Server Action?
Yes, that's one of the main use cases. Mutate data in the server action, then call revalidatePath to clear the cache for the affected pages before the response goes back to the client.
What do users see while a background rebuild is happening?
They get the stale cached version instantly — no waiting. The rebuild runs in the background. Once the new version is ready, the next request gets it. Nobody sits on a loading spinner. That's the whole point of the stale-while-revalidate pattern.
Can I turn off all caching globally for debugging?
You can add export const dynamic = "force-dynamic" to a layout, which forces every route under it to render per request. It's useful for debugging but shouldn't stay in production — you'd lose all the performance benefits. Remove it once you've figured out what's going on.
What to learn next
Once caching is working correctly, the next performance wins come from images and fonts. Using next/image and next/font correctly has a measurable effect on Core Web Vitals, which feeds into Google rankings. After that, if you're running a CMS-backed site, learn how to wire revalidateTag to a webhook so pages update the instant an editor publishes — no redeploy, no cron job.
Related on Baseline
- [Next.js App Router basics](/developers/nextjs/nextjs-app-router-basics)
- [Next.js metadata and SEO](/developers/nextjs/nextjs-metadata-and-seo)
Takeaways
Caching in the App Router isn't complicated once the mental model clicks: static by default, revalidate for freshness on a schedule, dynamic only when you genuinely need per-request data.
If you remember only one thing: export const revalidate must be a plain number literal. Not a constant, not an expression — a literal integer. That one mistake has caused more wasted debugging sessions than any other caching issue in Next.js, and it has no error message that points you there.