Caching is the default
In the App Router, fetch requests are cached by default. Static pages, server components, and route handlers all participate in a layered cache — which is great for performance until you ship dynamic data and wonder why changes don't show up.
Next.js 15+ changed some defaults around fetch caching. Always check your version's docs —
behavior differs from Next.js 14.
The four layers (mental model)
| Layer | What it stores | Typical use |
|---|---|---|
| Request memoization | Deduped fetch in one render | Same URL called twice in one tree |
| Data cache | Persistent fetch results | CMS content, product catalogs |
| Full route cache | Pre-rendered HTML + RSC payload | Static marketing pages |
| Router cache | Client-side segment cache | Soft navigations between routes |
You usually interact with the data cache and revalidation options directly.
Static by default
// Cached indefinitely (static)
const posts = await fetch('https://api.example.com/posts').then((r) => r.json())For content that rarely changes — like blog posts from MDX or a headless CMS — this is ideal.
Time-based revalidation
Re-fetch on an interval with next.revalidate:
const posts = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 } // refresh every hour
}).then((r) => r.json())Good for homepages and listing pages where stale-by-an-hour is acceptable.
On-demand revalidation with tags
Tag fetches so you can invalidate them from a webhook or server action:
// app/blog/page.tsx
const posts = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
}).then((r) => r.json())// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
export async function POST(request: Request) {
const { secret, tag } = await request.json()
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
revalidateTag(tag)
return Response.json({ revalidated: true })
}Pair tag-based revalidation with CMS webhooks. When an editor publishes, hit your revalidate endpoint instead of waiting for a timer.
Opting out entirely
For authenticated dashboards or real-time data:
const session = await fetch('https://api.example.com/me', {
cache: 'no-store'
}).then((r) => r.json())Or mark the whole route dynamic:
export const dynamic = 'force-dynamic'Route segment config
Page-level exports override fetch defaults for the entire segment:
export const revalidate = 60
export const dynamic = 'force-static'
export const fetchCache = 'default-no-store'Use these sparingly — prefer explicit fetch options so behavior stays visible at the call site.
Blog-specific pattern
For a Contentlayer or MDX blog, pages are often fully static at build time. Add revalidation only when you pull from an external API:
// Static: read from filesystem at build
import { getAllPosts } from '@/lib/posts'
export default function BlogPage() {
const posts = getAllPosts()
return <BlogList posts={posts} />
}// Dynamic CMS: cache with tags
const posts = await fetch(process.env.CMS_URL + '/posts', {
next: { tags: ['posts'], revalidate: 86400 }
}).then((r) => r.json())Conclusion
Choose caching at the data boundary: static for MDX and build-time content, revalidate for semi-fresh lists, tags for editor-driven updates, and no-store for user-specific views. When something looks stuck, trace which layer is holding the old value before reaching for force-dynamic.
Calling revalidatePath('/') in development may not behave like production. Test cache
invalidation in a preview deployment.
