React
|stacknotice.com
14 min left|
0%
|2,800 words
React

Next.js Caching Explained: The Complete Guide (2026)

Next.js App Router has 4 caching layers. Most developers fight them instead of using them. This guide explains each one, when it applies, and exactly how to invalidate it.

May 29, 202614 min read
Share:
Next.js Caching Explained: The Complete Guide (2026)

Next.js caching is the feature developers complain about most.

Not because it's bad — it's actually powerful — but because it's invisible until it isn't. You change data in the database, refresh the page, and see stale content. Or you wonder why your API call hits the network on every request when you expected it to be cached. The rules feel inconsistent.

They're not. Once you understand the four distinct caching layers and exactly when each applies, the behavior becomes predictable. This guide covers all of them — updated for Next.js 15.

The 4 caching layers

Next.js App Router has four independent caching mechanisms:

CacheWhat it storesDurationHow to invalidate
Request MemoizationDuplicate fetch() calls in one renderSingle requestAutomatic
Data Cachefetch() responsesPersistent (until revalidated)revalidatePath, revalidateTag, no-store
Full Route CacheRendered HTML + RSC payloadUntil revalidated or redeployrevalidatePath, dynamic functions
Router CacheClient-side RSC payloadSession (30s–5min)router.refresh(), server actions

They're independent. You can have stale data in the Router Cache while the Data Cache is fresh. Understanding which layer is causing your "why is this stale?" problem is the entire game.

Important: what changed in Next.js 15

Next.js 15 reversed some aggressive caching defaults from v14:

  • fetch() requests are no longer cached by default — in Next.js 14, every fetch was cached. In v15, the default is no-store. You opt into caching explicitly.
  • GET route handlers are no longer cached by default — same change, applied to API routes.
  • Client Router Cache TTL reduced — dynamic pages no longer stay in the client cache between navigations.

If you're migrating from Next.js 14, this is the most impactful breaking change. Your app probably becomes slightly slower (more network requests) but much more predictable.

Layer 1: Request Memoization

What it is: During a single server render, if you call fetch() with the same URL and options multiple times, Next.js only makes the network request once. Subsequent calls return the memoized result.

Scope: Single request. It resets after the response is sent.

// Both of these in the same render will only hit the network once
async function UserAvatar({ userId }: { userId: string }) {
  const user = await fetch(`/api/users/${userId}`).then(r => r.json())
  return <img src={user.avatarUrl} />
}
 
async function UserName({ userId }: { userId: string }) {
  const user = await fetch(`/api/users/${userId}`).then(r => r.json())
  return <span>{user.name}</span>
}

You can use both components on the same page without worrying about duplicate network requests. No need for prop drilling or shared state just to avoid a fetch call.

For non-fetch data sources, use React's cache():

import { cache } from 'react'
import { db } from '@/db'
import { users } from '@/db/schema'
import { eq } from 'drizzle-orm'
 
export const getUser = cache(async (id: string) => {
  return db.query.users.findFirst({ where: eq(users.id, id) })
})

Now getUser('123') called from multiple components in the same render only hits the database once. This is the pattern to use when you're fetching directly from the database with Drizzle or Prisma instead of going through an API.

Layer 2: Data Cache

What it is: A persistent server-side cache for fetch() responses. Survives across requests and deploys (on Vercel). This is the big one that surprises developers.

Default in Next.js 15: no-store (not cached). You opt in.

Opting into caching

// Cache indefinitely (until manually revalidated)
const data = await fetch('https://api.example.com/data', {
  cache: 'force-cache',
})
 
// Cache and revalidate after 60 seconds (time-based)
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 60 },
})
 
// Never cache (explicit, same as default in v15)
const data = await fetch('https://api.example.com/data', {
  cache: 'no-store',
})

Tagging cached data for targeted invalidation

// Tag the cached data
const products = await fetch('https://api.example.com/products', {
  next: { revalidate: 3600, tags: ['products'] },
})
 
// Invalidate all "products" cache entries (in a server action)
import { revalidateTag } from 'next/cache'
 
export async function updateProduct(id: string, data: ProductData) {
  await db.update(products).set(data).where(eq(products.id, id))
  revalidateTag('products')
}

unstable_cache for non-fetch data sources

When you're querying a database directly, fetch() caching doesn't apply. Use unstable_cache:

import { unstable_cache } from 'next/cache'
import { db } from '@/db'
 
export const getProducts = unstable_cache(
  async () => {
    return db.query.products.findMany({ orderBy: desc(products.createdAt) })
  },
  ['products-list'], // cache key
  {
    revalidate: 3600,
    tags: ['products'],
  }
)
// Invalidating it works the same way
import { revalidateTag } from 'next/cache'
revalidateTag('products')
unstable_cache is not actually unstable

Despite the unstable_ prefix, unstable_cache is production-ready and widely used. Vercel has indicated it will be stabilized in a future release with the same API.

Layer 3: Full Route Cache

What it is: The rendered HTML and RSC payload for a route is cached on the server. When the same route is requested again, Next.js serves the cached output without re-rendering.

When it applies: Only for statically rendered routes. If a route uses dynamic functions (cookies(), headers(), searchParams), it opts out of the Full Route Cache and renders on every request.

// Static route — eligible for Full Route Cache
export default async function BlogPage() {
  const posts = await getPosts() // Data Cache handles this
  return <PostList posts={posts} />
}
 
// Dynamic route — NOT eligible for Full Route Cache
import { cookies } from 'next/headers'
 
export default async function DashboardPage() {
  const cookieStore = await cookies() // This makes the route dynamic
  const theme = cookieStore.get('theme')
  return <Dashboard theme={theme?.value} />
}

Forcing dynamic rendering

// Force all routes in this segment to render dynamically
export const dynamic = 'force-dynamic'
 
// Or force static (useful for catching accidental dynamic usage)
export const dynamic = 'force-static'

Invalidating the Full Route Cache

import { revalidatePath } from 'next/cache'
 
// After updating a blog post:
export async function updatePost(id: string, data: PostData) {
  await db.update(posts).set(data).where(eq(posts.id, id))
  revalidatePath('/blog')           // revalidate the index
  revalidatePath(`/blog/${data.slug}`) // revalidate the specific post
}

revalidatePath purges the Full Route Cache AND the Data Cache for the specified path. It's the nuclear option — use it when you know exactly which pages are affected.

Layer 4: Router Cache (Client-side)

What it is: When a user navigates between pages, Next.js stores the RSC payload in memory on the client. When they navigate back, the cached version is served instantly without a server request.

Duration in Next.js 15:

  • Static routes: 5 minutes
  • Dynamic routes: 30 seconds

This cache is why navigating back to a list page feels instant after viewing a detail page. It also explains why data updated on the server sometimes doesn't appear until you refresh.

Invalidating the Router Cache

'use client'
import { useRouter } from 'next/navigation'
 
function DeleteButton({ id }: { id: string }) {
  const router = useRouter()
 
  async function handleDelete() {
    await deleteItem(id)
    router.refresh() // Purges Router Cache and re-fetches from server
  }
 
  return <button onClick={handleDelete}>Delete</button>
}

Server actions automatically invalidate the Router Cache for paths you call revalidatePath on:

'use server'
import { revalidatePath } from 'next/cache'
 
export async function deleteItem(id: string) {
  await db.delete(items).where(eq(items.id, id))
  revalidatePath('/items') // Client sees fresh data after this action
}

Common patterns

Pattern 1: Static marketing pages with ISR

// app/blog/[slug]/page.tsx
export const revalidate = 3600 // Revalidate every hour
 
export async function generateStaticParams() {
  const posts = await db.query.posts.findMany({ columns: { slug: true } })
  return posts.map(p => ({ slug: p.slug }))
}
 
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
  const post = await db.query.posts.findFirst({
    where: eq(posts.slug, params.slug)
  })
  return <BlogPost post={post} />
}

Pages generate at build time. Requests after 1 hour trigger a background revalidation (stale-while-revalidate). Users always see a fast response.

Pattern 2: Authenticated dashboard — always fresh

// app/dashboard/page.tsx
import { cookies } from 'next/headers'
 
// Accessing cookies makes this dynamic — no Full Route Cache
export default async function DashboardPage() {
  const { orgId } = await requireOrg()
 
  const data = await withRLS(orgId, () =>
    db.query.projects.findMany()
  )
 
  return <Dashboard data={data} />
}

For authenticated pages, dynamic rendering is usually what you want. Every request gets fresh data. The Data Cache still helps if you use unstable_cache for expensive queries.

Pattern 3: Revalidate on mutation

'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
 
export async function createPost(data: CreatePostInput) {
  const post = await db.insert(posts).values(data).returning()
 
  // Invalidate the blog listing (Full Route Cache + Data Cache)
  revalidatePath('/blog')
 
  // If using tags, also invalidate the tag
  revalidateTag('posts')
 
  return post[0]
}

Pattern 4: On-demand revalidation via API

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
 
export async function POST(req: NextRequest) {
  const secret = req.headers.get('x-revalidate-secret')
 
  if (secret !== process.env.REVALIDATION_SECRET) {
    return Response.json({ error: 'Invalid secret' }, { status: 401 })
  }
 
  const { tag } = await req.json()
  revalidateTag(tag)
 
  return Response.json({ revalidated: true, tag })
}

Your CMS or external service can POST to this endpoint to trigger revalidation when content changes.

Debugging cache issues

When data looks stale, work through the layers:

  1. Router Cache? Do a hard refresh (Ctrl+Shift+R). If that fixes it, it's the Router Cache.
  2. Data Cache? Check if you're using force-cache or revalidate without invalidation. Call revalidatePath or revalidateTag after mutations.
  3. Full Route Cache? Check if the route is static. Add dynamic functions or export const dynamic = 'force-dynamic' to opt out.
  4. Still stale? Log inside the server component — if it's not logging, the Full Route Cache is serving the response.
// Quick debugging: add this to the top of a server component
console.log('Rendering at', new Date().toISOString())

If this doesn't log on every request, something is cached upstream.

What to cache and what not to

CacheWhat to cache
Data CachePublic data that changes infrequently (products, blog posts, pricing)
Full Route CacheMarketing pages, blog, docs
Don't cacheAuthenticated data, user-specific data, real-time data

The default in Next.js 15 — no caching — is a safe starting point. Add caching where you've identified it's needed, with explicit invalidation for every mutation.

For how caching integrates with server actions, see the server actions complete guide. And for the full App Router setup these patterns live in, see the Next.js App Router guide.

#nextjs#react#webdev#typescript#javascript
Share:

Enjoyed this article?

Join 2,400+ developers getting weekly insights on Claude Code, React, and AI tools.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.