Skip to main content

Caching in Next.js

Next.js caching stores fetch responses, rendered HTML, and route payloads across four layers so pages load fast without any custom caching logic written by hand.

Theory

TL;DR

  • Four layers: request memoization (deduplicates during one render), data cache (persists across requests and deploys), full route cache (stores HTML at build time), router cache (client-side, per browser tab)
  • Analogy: a restaurant kitchen with prep stations (memoization), a walk-in fridge (data cache), pre-plated meals (full route cache), and a waiter's notepad (router cache)
  • Server caches survive deployments; router cache dies when the tab closes
  • Decision rule: cache: 'no-store' for live data, default static for blogs and product catalogs
  • Biggest gotcha: fetch with no-store does NOT bypass the full route cache unless you also add dynamic = 'force-dynamic'

Quick example

tsx
// app/posts/page.tsx // Two components fetch the same URL in one render pass // Network call happens exactly once (request memoization) async function PostList() { const res = await fetch('https://api.example.com/posts', { cache: 'force-cache' }) const posts = await res.json() return <ul>{posts.map((p: any) => <li key={p.id}>{p.title}</li>)}</ul> } export default async function Page() { return ( <> <PostList /> {/* fetch runs here */} <PostList /> {/* no network call, reuses memoized result */} </> ) }

The second <PostList /> reuses the already-fetched data. No extra HTTP request. This is request memoization at work.

The four cache layers

Next.js 14+ stacks caches in a specific order. Understanding which layer fires first prevents most interview mistakes.

Request memoization runs during a single server render. React deduplicates fetch calls with the same URL and options by storing results in a per-request Map. It resets after the render is done. For Prisma or Drizzle queries that don't go through fetch, use React.cache() to get the same deduplication.

Data cache persists across requests and deployments. Think of it as a CDN for your server-side fetch results. On Vercel it uses the Edge KV store; locally it writes to the filesystem. It survives server restarts unless you explicitly invalidate it with revalidateTag() or revalidatePath().

Full route cache stores the rendered HTML and RSC payload for static routes at build time. This is what makes a static Next.js page feel instant: the server serves a file, not a computation. A route stays fully static as long as no dynamic functions (cookies(), headers(), searchParams) are called and all fetches are cached.

Router cache lives in the browser. It stores RSC payloads of routes the user has already visited, making back-navigation instant. It also prefetches <Link> targets automatically. The cache expires after 30 seconds of staleness or when the tab closes.

Key difference: static vs dynamic rendering

Static rendering means Next.js runs the page at build time (or on the first request for ISR), stores the HTML and RSC payload in the full route cache, and serves that file for every subsequent request. Good for blogs, product pages, marketing sites.

Dynamic rendering is triggered by reading cookies(), headers(), or searchParams, or by setting dynamic = 'force-dynamic'. The server re-runs the component tree on every request. No full route cache involved.

But the data cache still works inside dynamic routes. A page can re-render per request and still reuse cached API responses. These are two independent switches, and most teams treat them as one.

When to use

  • Static blog post - default fetch or next: { revalidate: 3600 } (data cache, ISR)
  • Ecommerce product page - full route cache at build, revalidateTag('product') on stock update
  • User dashboard - cookies() or { cache: 'no-store' } to force dynamic rendering
  • Admin panel - export const dynamic = 'force-dynamic' at the page level
  • Live metrics - cache: 'no-store' together with dynamic = 'force-dynamic'
  • A/B test page - no-store plus custom headers to pick the variant per request

How revalidation works

Two strategies. Time-based sets a staleness window:

tsx
const res = await fetch('https://api.itlead.org/problems', { next: { revalidate: 300 } // stale after 5 minutes })

Event-based tags let you invalidate on demand:

tsx
// When fetching const res = await fetch(url, { next: { tags: ['problems'] } }) // In a Server Action after a write import { revalidateTag } from 'next/cache' revalidateTag('problems')

export const revalidate = 3600 at the page level is a fallback for all fetches on that page. Individual fetch calls can override it with their own revalidate value. The smallest value wins when determining when the page regenerates.

Common mistakes

Mistake 1: no-store without force-dynamic

tsx
// Wrong: HTML is still cached at build time export default async function Page() { const data = await fetch('/api/live', { cache: 'no-store' }) return <div>{data.value}</div> } // Fix: tell Next.js the whole route is dynamic export const dynamic = 'force-dynamic' export default async function Page() { const data = await fetch('/api/live', { cache: 'no-store' }) return <div>{data.value}</div> }

fetch with no-store skips the data cache. But the full route cache still snapshots the HTML at build time. You need both.

Mistake 2: revalidate: 0 expecting it to skip everything

revalidate: 0 only affects the data cache. Request memoization and the full route cache still apply. Use cache: 'no-store' with dynamic = 'force-dynamic' to fully opt out.

Mistake 3: stale data after mutation because of router cache

The user submits a form, a Server Action updates the database, but the next navigation shows old data. The browser's router cache served the pre-mutation RSC payload. Fix:

tsx
import { revalidatePath } from 'next/cache' export async function updateProfile(formData: FormData) { 'use server' await db.user.update(...) revalidatePath('/profile') // clears both server and router cache for this path }

Or call router.refresh() on the client after the mutation.

Mistake 4: different fetch options break memoization

Memoization keys on URL plus options plus body. Two fetches to the same URL with different cache values count as two separate requests. Normalize options across components that share a data source.

Mistake 5: assuming next dev uses the full route cache

Development mode bypasses the full route cache entirely. A page that feels static in production re-renders on every request in dev. Spent a good half-hour chasing a "caching bug" once before remembering: always verify caching behavior with next build && next start, not in dev.

Real-world usage

  • Vercel Commerce (nextjs-commerce repo): full route cache for product detail pages, data cache with revalidateTag for inventory
  • Supabase + Next.js starters: revalidate: 60 for user lists, no-store for edit pages
  • Next.js dashboards with NextAuth: reading headers() forces dynamic rendering for authenticated routes
  • vs React Query: Next.js caching handles server-side fetches at no extra cost; React Query fits better for client-side mutations and optimistic updates

Follow-up questions

Q: What is the difference between the data cache and the full route cache?
A: Data cache stores raw fetch responses (JSON) across requests. Full route cache stores the rendered HTML and RSC payload for the whole static route at build time.

Q: If export const revalidate = 3600 is set at the page level, what happens to individual fetch calls with their own revalidate?
A: The page-level value is a fallback. Any fetch with its own next: { revalidate } overrides it for that specific call. The smallest value determines when the page regenerates.

Q: Hard refresh vs soft navigation: what changes?
A: Soft navigation (clicking a <Link>) hits the router cache first. Hard refresh skips it entirely, goes to the server, which then checks the full route cache and data cache in that order.

Q: Does reading searchParams in a page component always make the route dynamic?
A: Yes. The moment Next.js sees searchParams accessed, the route opts out of the full route cache. The page re-renders per request, but the data cache still works for fetches inside it.

Q (senior): Edge runtime vs Node.js runtime: how does caching behave differently?
A: On Vercel, Edge runtime uses a globally distributed KV store, so cached data is shared across all regions. Node.js runtime uses the local filesystem, giving each server instance its own cache. This affects cache invalidation timing in multi-region deployments.

Q (senior): Why does next dev never show the full route cache even for static pages?
A: Development mode intentionally bypasses it so every change reflects immediately. The full route cache is only populated by next build. This is a common source of confusion when dev behavior does not match production.

Examples

Basic: request memoization with React.cache

tsx
// lib/data.ts import { cache } from 'react' import { db } from '@/lib/db' // Works for ORM queries that don't go through fetch export const getUser = cache(async (id: string) => { return db.user.findUnique({ where: { id } }) }) // app/profile/[id]/page.tsx import { getUser } from '@/lib/data' async function Avatar({ id }: { id: string }) { const user = await getUser(id) // DB call happens once return <img src={user.avatar} alt={user.name} /> } export default async function Page({ params }: { params: { id: string } }) { const user = await getUser(params.id) // reuses result, no second query return ( <div> <h1>{user.name}</h1> <Avatar id={params.id} /> </div> ) } // Output: one DB query for both components in the same render

React.cache() wraps a function so repeated calls with the same arguments return the memoized result. It resets per request, so there is no stale data across users.

Intermediate: ISR product page with tag-based revalidation

tsx
// app/products/[slug]/page.tsx export default async function ProductPage({ params }: { params: { slug: string } }) { const res = await fetch(`https://api.example.com/products/${params.slug}`, { next: { revalidate: 3600, // regenerate at minimum every hour tags: [`product-${params.slug}`] // or on demand via revalidateTag } }) const product = await res.json() return ( <div> <h1>{product.name}</h1> <p>Stock: {product.stock}</p> <p>${product.price}</p> </div> ) } // app/actions/updateStock.ts 'use server' import { revalidateTag } from 'next/cache' export async function updateStock(slug: string, newStock: number) { await fetch(`https://api.example.com/products/${slug}`, { method: 'PATCH', body: JSON.stringify({ stock: newStock }) }) revalidateTag(`product-${slug}`) // only this product's page refreshes }

The page serves cached HTML until either an hour passes or updateStock fires revalidateTag. Everything else stays cached. This is the ISR pattern most ecommerce projects use.

Advanced: dynamic route with partial data caching

tsx
// app/dashboard/page.tsx // Route is dynamic (reads cookies for auth) // but the public stats fetch is still cached import { cookies } from 'next/headers' export default async function Dashboard() { // Reading cookies makes the whole route dynamic const session = cookies().get('session-token') // This fetch still uses the data cache (5-minute window) const statsRes = await fetch('https://api.example.com/stats', { next: { revalidate: 300 } }) // This one is fresh on every request const userRes = await fetch(`/api/user/${session?.value}`, { cache: 'no-store' }) const user = await userRes.json() const stats = await statsRes.json() return ( <div> <h1>Welcome, {user.name}</h1> <p>Total users: {stats.total}</p> {/* from cache, up to 5 min stale */} </div> ) } // Output: page re-renders per request, user data is always fresh, // stats skip the API call most of the time

Reading cookies() makes the route dynamic. But individual fetches inside it still use the data cache unless you pass no-store. Dynamic rendering and data caching are independent. Combining them is how you get fresh user-specific data alongside cached shared data in the same render.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?