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)

LayerWhat it storesTypical use
Request memoizationDeduped fetch in one renderSame URL called twice in one tree
Data cachePersistent fetch resultsCMS content, product catalogs
Full route cachePre-rendered HTML + RSC payloadStatic marketing pages
Router cacheClient-side segment cacheSoft navigations between routes

You usually interact with the data cache and revalidation options directly.

Static by default

code
// 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:

code
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:

code
// app/blog/page.tsx
const posts = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] }
}).then((r) => r.json())
code
// 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:

code
const session = await fetch('https://api.example.com/me', {
  cache: 'no-store'
}).then((r) => r.json())

Or mark the whole route dynamic:

code
export const dynamic = 'force-dynamic'

Route segment config

Page-level exports override fetch defaults for the entire segment:

code
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:

code
// Static: read from filesystem at build
import { getAllPosts } from '@/lib/posts'
 
export default function BlogPage() {
  const posts = getAllPosts()
  return <BlogList posts={posts} />
}
code
// 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.