Next.js App Router has four separate caching layers. If you don't understand them, you'll waste hours debugging stale data or wonder why your builds are so fast.
The Four Caches
1. Request Memoization -- deduplicates fetch() in one render
2. Data Cache -- persists fetch() across deploys
3. Full Route Cache -- static HTML + RSC payload for entire routes
4. Router Cache -- prefetched pages in browser memory
1. Request Memoization
Within a single request, identical fetch() calls return the same result without hitting the network:
// Both components call the same URL -- only one HTTP request is made
async function Component1() {
const user = await fetch('/api/user/1') // HTTP request
return <div>{user.name}</div>
}
async function Component2() {
const user = await fetch('/api/user/1') // Returns memoized result
return <div>{user.email}</div>
}
This only works for GET requests using the native fetch(). Lasts only for the duration of one server render.
2. Data Cache
fetch() results are cached persistently on the server (survived across requests and even deployments):
// Cache forever (default, same as { cache: 'force-cache' })
const data = await fetch('https://api.example.com/data')
// Cache for 60 seconds (revalidate via time)
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }
})
// Never cache (always fresh data)
const data = await fetch('https://api.example.com/data', {
cache: 'no-store'
})
For non-fetch data sources (Prisma, Redis, external SDKs), use cache() from React:
import { cache } from 'react'
import { unstable_cache } from 'next/cache'
// cache() -- Request Memoization only (single render)
const getUser = cache(async (id: string) => {
return db.users.findById(id)
})
// unstable_cache() -- persistent Data Cache like fetch()
const getCachedUser = unstable_cache(
async (id: string) => db.users.findById(id),
['user'], // Cache key
{ revalidate: 300, tags: ['users'] }
)
3. Full Route Cache
Static routes are rendered at build time and stored as HTML + RSC payload:
// app/blog/[slug]/page.tsx
// Tell Next.js which slugs to pre-render at build time
export async function generateStaticParams() {
const posts = await db.posts.findAll()
return posts.map(p => ({ slug: p.slug }))
}
// This page is statically rendered at build time
export default async function BlogPost({ params }) {
const post = await getPost(params.slug)
return <Article post={post} />
}
Dynamic routes (reading cookies, headers, searchParams) opt out of full route caching automatically.
Revalidation
Time-Based
// Revalidate entire route every hour
export const revalidate = 3600
// Or per-fetch
const data = await fetch(url, { next: { revalidate: 3600 } })
On-Demand (Tag-Based)
// Tag your fetches
const posts = await fetch('/api/posts', { next: { tags: ['posts'] } })
// In a Server Action or Route Handler:
import { revalidateTag } from 'next/cache'
export async function createPost(data) {
await db.posts.create(data)
revalidateTag('posts') // All fetches tagged 'posts' are invalidated
}
Path-Based
import { revalidatePath } from 'next/cache'
// Purge the full route cache for a specific path
revalidatePath('/blog')
revalidatePath('/blog/[slug]', 'page') // Purge all blog post pages
4. Router Cache
Client-side cache in the browser. Prefetched routes are stored in memory for 30s (dynamic) or 5 minutes (static). Not configurable -- it just works.
If you have a Server Action that mutates data, call revalidatePath or revalidateTag inside it to invalidate both the Data Cache and Router Cache:
'use server'
import { revalidateTag } from 'next/cache'
export async function updatePost(id: string, data: Partial<Post>) {
await db.posts.update(id, data)
revalidateTag('posts') // Tells Next.js to refetch all 'posts' tagged fetches
}
Common Mistakes
1. Forgetting no-store for user-specific data
// Bad: this caches one user's data for all users
const user = await fetch('/api/me')
// Good
const user = await fetch('/api/me', { cache: 'no-store' })
2. Not tagging fetches
// Can't invalidate specifically
const posts = await fetch('/api/posts')
// Can invalidate with revalidateTag('posts')
const posts = await fetch('/api/posts', { next: { tags: ['posts'] } })
3. Using cookies/headers in a cached route
Reading cookies() or headers() in a page automatically opts the entire route out of full route caching. This is usually what you want for authenticated pages.
The caching system is aggressive by default. When in doubt, check what's cached with next dev --turbo and look at the dev tools' Network tab.