Skip to content
Homeβ€Ί JavaScriptβ€Ί Next.js 16 Caching Strategies Explained: The 2026 Guide

Next.js 16 Caching Strategies Explained: The 2026 Guide

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: React.js β†’ Topic 29 of 32
Complete breakdown of Next.
πŸ”₯ Advanced β€” solid JavaScript foundation required
In this tutorial, you'll learn
Complete breakdown of Next.
  • Next.js 16 has four server caching layers plus a client Router Cache
  • fetch() defaults to no-store in Next.js 15+ β€” you must opt-in to caching with revalidate or cache: 'force-cache'
  • Partial Prerendering splits pages into static shells and dynamic streaming holes β€” dynamic APIs only affect their boundary
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑Quick Answer
  • Next.js 16 has four server caching layers: Full Route Cache, Partial Prerendering, Data Cache (fetch), and React cache()
  • Plus a client-side Router Cache that stores RSC payloads (30s for static, 5min for dynamic)
  • Full Route Cache stores entire rendered pages at build time β€” only when all data is cacheable
  • Partial Prerendering splits a page into a static shell + dynamic streaming holes β€” one strategy per component
  • fetch() defaults to cache: 'no-store' in Next.js 15+ β€” you must opt-in with revalidate or cache: 'force-cache'
  • React cache() deduplicates identical fetches within a single render pass β€” not a persistent cache
  • Biggest mistake: assuming fetch() caches by default β€” in Next.js 16 it doesn't, you must opt in
🚨 START HERE
Next.js Caching Quick Debug Reference
Fast commands for diagnosing Next.js caching issues in production
🟑Stale data on a specific page
Immediate ActionCheck the response cache header
Commands
curl -I https://your-site.com/products/123 | grep -i 'x-nextjs-cache'
curl -s -D - https://your-site.com/products/123 -o /dev/null | grep x-nextjs-cache
Fix NowIf HIT or STALE, the page is cached β€” add next: { revalidate: 60 } to the fetch() or call revalidatePath() from a webhook
🟑Build generates too many static pages
Immediate ActionCheck which routes are being statically generated
Commands
next build 2>&1 | grep 'β—‹\|●\|Ζ’'
cat .next/routes-manifest.json | jq '.staticRoutes | length'
Fix Nowβ—‹ = static, ● = SSG, Ζ’ = dynamic. Remove cache: 'force-cache' or add export const dynamic = 'force-dynamic'
🟑React cache() not deduplicating fetches
Immediate ActionVerify the fetch call is wrapped at module level, not inside the component
Commands
grep -rn 'cache(' src/lib/ --include='*.ts' | head -10
cat src/app/page.tsx | grep -B 2 -A 5 'cache('
Fix NowMove the cached function to a separate lib/data.ts file and wrap with cache() at the top level
🟑On-demand revalidation not working
Immediate ActionCheck the revalidation route handler
Commands
cat src/app/api/revalidate/route.ts
curl -X POST https://your-site.com/api/revalidate -H 'Authorization: Bearer TOKEN' -d '{"tag":"products"}'
Fix NowVerify the secret matches, revalidatePath or revalidateTag is called correctly, and the response returns { revalidated: true }
Production IncidentProduct prices stale for 24 hours due to Full Route CacheAn e-commerce site deployed with Next.js full route cache enabled on product pages. Price updates from the CMS were invisible until the next build β€” users saw old prices for up to 24 hours.
SymptomCustomer support tickets about incorrect prices started 2 hours after a CMS price update. The prices were correct in the CMS but wrong on the frontend. A redeployment fixed the issue temporarily.
AssumptionThe team assumed fetch() would stay fresh by default in Next.js 16, but they had explicitly opted into permanent caching with cache: 'force-cache' for performance.
Root causeThe product page used fetch(https://api.example.com/products/${slug}, { cache: 'force-cache' }). This opts the data into the Data Cache permanently, and combined with generateStaticParams, the page was fully static at build time. The result was baked into the Full Route Cache. Without revalidate or on-demand invalidation, the cached HTML persisted until the next deploy.
FixRemoved cache: 'force-cache' and added next: { revalidate: 300 } to the fetch() call β€” this enables stale-while-revalidate every 5 minutes. For prices that change frequently, added on-demand revalidation via revalidateTag('products') triggered by a CMS webhook.
Key Lesson
In Next.js 16 fetch() defaults to no-store β€” you must opt-in to caching with revalidate or cache: 'force-cache'Full Route Cache bakes cached fetch results into static HTML β€” stale data persists without revalidationCMS-driven content needs webhook-triggered revalidation β€” do not rely on time-based revalidation alone
Production Debug GuideDiagnose stale data, slow pages, and cache invalidation issues
Page shows stale data after CMS update→Check if the page is in the Full Route Cache — look for x-nextjs-cache: HIT or STALE. Add next: { revalidate } to fetch() or trigger on-demand revalidation via revalidatePath().
Page is slow despite using static generation→Check if a dynamic fetch() (no-store) or cookies() call is forcing the segment to stream — with PPR only that Suspense boundary streams, not the whole page.
Same data fetched multiple times in one request→Wrap the fetch call with React cache() — it deduplicates identical calls within a single render pass.
Partial Prerendering not working — entire page streams→Verify the static shell has no uncached data fetches at the root. Dynamic content must be wrapped in Suspense and use cache: 'no-store'.
fetch() cache not invalidating after revalidateTag() call→Verify the tag on the fetch call matches the tag passed to revalidateTag(). Tags are case-sensitive and must match exactly.
Build takes too long due to excessive static generation→Check how many pages are being statically generated. Use dynamicParams: true to generate on-demand, or remove cache: 'force-cache' to skip Full Route Cache.

Next.js 16 ships with four distinct server caching layers plus a client Router Cache. Each operates at a different level β€” build time, request time, component render time β€” and each has different invalidation semantics. Engineers who treat them as interchangeable produce either stale pages or unnecessarily slow ones.

The core change in Next.js 15: fetch() no longer caches by default. In Next.js 13/14, fetch() defaulted to force-cache, causing widespread stale data bugs. In Next.js 16, fetch() defaults to no-store β€” data is fresh on every request unless you explicitly opt into caching with next: { revalidate } or cache: 'force-cache'.

The correct approach: understand what each layer caches, when it invalidates, and how to opt in selectively. This article breaks down all four layers with production patterns, debugging commands, and the failure scenarios that catch teams off guard.

Layer 1: Full Route Cache (Build-Time Static Generation)

Full Route Cache stores the entire rendered HTML and RSC payload at build time. When a user requests a statically generated page, Next.js serves the cached HTML directly β€” no server-side rendering, no data fetching, no React rendering. This is the fastest possible response.

Pages enter the Full Route Cache when all data fetching opts into caching (using fetch with cache: 'force-cache' or next: { revalidate: N }) and the page has no dynamic dependencies (no cookies(), headers(), or fetch with no-store). The cache key is the route path.

Invalidation happens in three ways: a new build (redeploy), time-based revalidation (revalidate: N seconds), or on-demand revalidation (revalidatePath() or revalidateTag() called from a route handler or server action). Without explicit revalidation, cached pages persist indefinitely until the next build.

io.thecodeforge.nextjs.full-route-cache.tsx Β· TSX
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
// ============================================
// Full Route Cache β€” Static Generation Patterns
// ============================================

// ---- Default in Next.js 16: fetch is NOT cached ----
// This page is dynamic by default β€” fresh data every request

// File: app/products/[slug]/page.tsx

interface Product {
  id: string
  slug: string
  name: string
  price: number
  description: string
}

// This fetch uses the default: cache: 'no-store' β€” fresh every request
async function getProductFresh(slug: string): Promise<Product> {
  const res = await fetch(`https://api.example.com/products/${slug}`)
  return res.json()
}

// ---- Opt-in to permanent cache: force-cache ----
// Use for truly static content (rarely)

async function getProductStatic(slug: string): Promise<Product> {
  const res = await fetch(`https://api.example.com/products/${slug}`, {
    cache: 'force-cache', // Opt-in: cache permanently until revalidate
  })
  return res.json()
}

// ---- Opt-in to ISR: stale-while-revalidate ----
// Serve cached data, regenerate in background after N seconds

async function getProductWithRevalidation(slug: string): Promise<Product> {
  const res = await fetch(`https://api.example.com/products/${slug}`, {
    next: { revalidate: 300 }, // Regenerate every 5 minutes
  })
  return res.json()
}

// ---- On-demand revalidation: webhook-triggered ----

// File: app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  const { path, tag, secret } = await request.json()

  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  if (path) {
    revalidatePath(path)
    return NextResponse.json({ revalidated: true, path })
  }

  if (tag) {
    revalidateTag(tag)
    return NextResponse.json({ revalidated: true, tag })
  }

  return NextResponse.json({ error: 'Provide path or tag' }, { status: 400 })
}

// ---- Tagged fetch: revalidate groups of data ----

async function getProductByTag(slug: string): Promise<Product> {
  const res = await fetch(`https://api.example.com/products/${slug}`, {
    next: {
      revalidate: 3600,
      tags: ['products', `product-${slug}`],
    },
  })
  return res.json()
}

// ---- generateStaticParams: pre-generate known routes ----

export async function generateStaticParams() {
  const products = await fetch('https://api.example.com/products', {
    cache: 'force-cache',
  }).then((res) => res.json())

  return products.map((product: Product) => ({
    slug: product.slug,
  }))
}
Mental Model
Full Route Cache = Pre-Built HTML
Full Route Cache is a pre-built box β€” fast to ship, but the contents only change when you rebuild or explicitly invalidate.
  • fetch() does NOT cache by default in Next.js 16 β€” add next: { revalidate } or cache: 'force-cache' to opt in
  • revalidate: N serves stale data immediately, regenerates in background β€” stale-while-revalidate pattern
  • On-demand revalidation via revalidatePath() or revalidateTag() β€” triggered by webhooks, not timers
  • Tagged fetch groups multiple data sources β€” revalidateTag('products') invalidates all tagged fetches
  • generateStaticParams pre-generates known routes β€” pages are built at build time only if data is cacheable
πŸ“Š Production Insight
fetch() defaults to no-store in Next.js 16 β€” data is fresh unless you explicitly opt into caching.
Without revalidate or on-demand invalidation, Full Route Cache pages persist until next deploy.
Rule: always set next: { revalidate } or cache: 'force-cache' when you want caching β€” never assume it.
🎯 Key Takeaway
Full Route Cache serves pre-built HTML β€” the fastest response, but only when you opt data into caching.
Three invalidation methods: redeploy, time-based revalidate: N, on-demand revalidatePath/revalidateTag.
Rule: CMS-driven content needs webhook-triggered revalidation β€” time-based alone causes stale windows.

Layer 2: Partial Prerendering (Static Shell + Dynamic Holes)

Partial Prerendering (PPR) splits a page into a static shell and dynamic streaming holes. The static shell is pre-rendered at build time and served instantly. Dynamic parts are wrapped in Suspense boundaries and streamed in as their data resolves.

PPR is Next.js 16's default. Instead of choosing between fully static and fully dynamic, PPR lets you mix both on the same page. The header, navigation, and layout are static. The personalized content, real-time data, and user-specific elements stream in.

The key constraint: dynamic content must be wrapped in a Suspense boundary AND use uncached data (fetch with no-store, cookies(), headers()). With PPR, a dynamic API only forces that Suspense boundary to stream β€” it does not make the entire page dynamic.

io.thecodeforge.nextjs.partial-prerendering.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
// ============================================
// Partial Prerendering β€” Static Shell + Dynamic Streaming
// ============================================

// File: app/dashboard/page.tsx
import { Suspense } from 'react'

// ---- Static shell: pre-rendered at build time ----

function DashboardHeader() {
  return (
    <header className="border-b p-4">
      <h1 className="text-2xl font-bold">Dashboard</h1>
      <nav className="mt-2 flex gap-4">
        <a href="/dashboard/overview">Overview</a>
        <a href="/dashboard/analytics">Analytics</a>
      </nav>
    </header>
  )
}

// ---- Dynamic holes: streamed with Suspense ----

async function RevenueCard() {
  const res = await fetch('https://api.example.com/revenue/today', {
    cache: 'no-store', // Dynamic β€” fresh every request
  })
  const data = await res.json()

  return (
    <div className="rounded-lg border p-4">
      <p className="text-sm text-muted-foreground">Today's Revenue</p>
      <p className="text-3xl font-bold">${data.total.toLocaleString()}</p>
    </div>
  )
}

async function ActivityFeed() {
  const res = await fetch('https://api.example.com/activity/recent', {
    cache: 'no-store',
  })
  const activities = await res.json()

  return (
    <div className="space-y-2">
      {activities.map((activity: any) => (
        <div key={activity.id} className="flex items-center gap-2 text-sm">
          <span>{activity.description}</span>
        </div>
      ))}
    </div>
  )
}

function RevenueSkeleton() {
  return <div className="h-24 animate-pulse rounded-lg bg-muted" />
}

// ---- Main page: static shell + Suspense-wrapped dynamic holes ----

export default function DashboardPage() {
  return (
    <div>
      <DashboardHeader />
      <main className="grid gap-4 p-4 sm:grid-cols-2">
        <Suspense fallback={<RevenueSkeleton />}> 
          <RevenueCard />
        </Suspense>
        <Suspense fallback={<RevenueSkeleton />}> 
          <ActivityFeed />
        </Suspense>
      </main>
    </div>
  )
}

// ---- With PPR: dynamic APIs only affect their boundary ----
// export default async function Page({ searchParams }) {
//   return (
//     <Suspense fallback={...}>
//       <ComponentUsingSearchParams searchParams={searchParams} />
//     </Suspense>
//   )
// }
Mental Model
PPR = Pre-Built Frame + Live Content
Partial Prerendering is a picture frame you pre-build, with a live video playing inside β€” the frame is instant, the content streams.
  • Static shell renders at build time β€” header, nav, layout are cached in Full Route Cache
  • Dynamic holes are wrapped in Suspense β€” they stream in as data resolves at request time
  • Fallback UI (skeletons) shows immediately while dynamic content loads β€” no blank screen
  • With PPR, dynamic APIs only force their Suspense boundary to stream β€” not the entire page
  • PPR is default in Next.js 16 β€” opt out with export const dynamic = 'force-dynamic'
πŸ“Š Production Insight
PPR requires Suspense boundaries around every dynamic component β€” missing one delays the static shell.
With PPR enabled, cookies() or searchParams only make that component dynamic, not the whole page.
Rule: if the static shell is slow, check for uncached fetches at the root level outside Suspense.
🎯 Key Takeaway
PPR splits pages into static shells and dynamic streaming holes β€” the best of both worlds.
Dynamic content must be inside Suspense with no-store data β€” PPR streams only that boundary.
Rule: move all dynamic data fetching into Suspense-wrapped components.

Layer 3: fetch() Cache (Per-Request Data Freshness)

fetch() in Next.js extends the native Fetch API with caching options. In Next.js 15+, each fetch call defaults to cache: 'no-store' β€” fresh data on every request unless you opt in. You can independently control caching: permanently (force-cache), never (no-store, the default), or with time-based revalidation (revalidate: N).

This is the most granular caching layer. A single page can have five fetch calls with five different cache strategies. Product data might revalidate every 5 minutes. User data might never cache. Static configuration might cache permanently.

The critical nuance: fetch() caching only works in Server Components. In development, every request triggers a fresh fetch even with caching enabled. In production, the fetch result is stored in the Data Cache.

io.thecodeforge.nextjs.fetch-cache.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// ============================================
// fetch() Cache β€” Per-Request Data Freshness Control
// ============================================

// ---- Cache options reference (Next.js 16) ----

// Option 1: no-store (DEFAULT)
// Data is fetched fresh on every request β€” never cached
async function getUserSession(token: string) {
  const res = await fetch('https://api.example.com/session', {
    cache: 'no-store',
    headers: { Authorization: `Bearer ${token}` },
  })
  return res.json()
}

// Option 2: force-cache
// Data is fetched once and cached permanently until revalidated
async function getSiteConfig() {
  const res = await fetch('https://api.example.com/config', {
    cache: 'force-cache',
  })
  return res.json()
}

// Option 3: revalidate (stale-while-revalidate)
// Serve cached data, regenerate in background after N seconds
async function getProductList() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 300 }, // 5 minutes
  })
  return res.json()
}

// Option 4: Tagged revalidation
async function getProductsByCategory(category: string) {
  const res = await fetch(`https://api.example.com/products?category=${category}`, {
    next: {
      revalidate: 600,
      tags: ['products', `category-${category}`],
    },
  })
  return res.json()
}

// ---- Mixing cache strategies on one page ----

export default async function ProductPage({ params }: { params: { slug: string } }) {
  const config = await getSiteConfig() // cached permanently
  const product = await fetch(`https://api.example.com/products/${params.slug}`, {
    next: { revalidate: 300 },
  }).then(r => r.json())
  const pricing = await fetch(`https://api.example.com/pricing/${params.slug}`, {
    cache: 'no-store',
  }).then(r => r.json())

  return (
    <div>
      <h1>{product.name}</h1>
      <p>${pricing.effectivePrice}</p>
    </div>
  )
}
⚠ fetch() Defaults to no-store β€” Opt In to Cache
πŸ“Š Production Insight
fetch() defaults to no-store in Next.js 16 β€” you must explicitly opt into caching.
Without next: { revalidate } or cache: 'force-cache', data is fetched fresh on every request.
Rule: every fetch() that should be cached must have an explicit cache option.
🎯 Key Takeaway
fetch() cache is per-request granularity β€” each call independently controls freshness.
Three options: no-store (default, fresh), force-cache (permanent), revalidate: N (stale-while-revalidate).
Rule: if data is slow, check if you forgot to add caching β€” the default is no-store, not force-cache.

Layer 4: React cache() (Request-Level Deduplication)

React cache() deduplicates identical function calls within a single render pass. It is not a persistent cache β€” it does not survive across requests or builds. It prevents the same data from being fetched multiple times when the same function is called from different components during one server render.

The common scenario: a layout and a page both need the current user. Without cache(), getUser() is called twice. With cache(), the second call returns the memoized result from the first call. The function must be defined at the module level β€” not inside a component.

io.thecodeforge.nextjs.react-cache.tsx Β· TSX
12345678910111213141516171819202122232425262728293031323334353637383940
// ============================================
// React cache() β€” Request-Level Deduplication
// ============================================

import { cache } from 'react'

// ---- CORRECT: cache() at module level ----

export const getUser = cache(async (userId: string) => {
  console.log('Fetching user:', userId) // Logs once per unique userId per request
  const res = await fetch(`https://api.example.com/users/${userId}`, {
    cache: 'no-store',
  })
  return res.json()
})

export const getProduct = cache(async (slug: string) => {
  const res = await fetch(`https://api.example.com/products/${slug}`, {
    next: { revalidate: 300 },
  })
  return res.json()
})

// ---- Usage: multiple components, one fetch ----

// File: app/layout.tsx
import { getUser } from '@/lib/data'

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const user = await getUser('current') // First call β€” fetches
  return <html><body><nav>{user.name}</nav>{children}</body></html>
}

// File: app/dashboard/page.tsx
import { getUser } from '@/lib/data'

export default async function DashboardPage() {
  const user = await getUser('current') // Second call β€” returns cached result
  return <div>Welcome, {user.name}</div>
}
Mental Model
React cache() = Request-Scoped Memoization
React cache() is a single-request memo β€” it remembers results for one render pass, then forgets everything.
  • cache() deduplicates identical calls within one server render β€” not across requests or builds
  • Must be defined at module level β€” defining inside a component creates a new cache per render
  • Combines with fetch() options: cache() for deduplication, fetch options for persistence control
  • Use when multiple components need the same data β€” layout + page + sidebar all calling getUser()
πŸ“Š Production Insight
React cache() deduplicates within one render pass β€” it is not a persistent cache.
Defining cache() inside a component creates a new cache per render β€” no deduplication happens.
Rule: always define cached functions at module level in a separate data layer file.
🎯 Key Takeaway
React cache() is request-scoped memoization β€” deduplicates identical calls within one server render.
Must be at module level β€” component-level cache() creates a new instance per render.
Rule: combine cache() with fetch() options β€” deduplication within request, freshness across requests.

Choosing the Right Caching Strategy

The four caching layers are not interchangeable. Each solves a different problem at a different level. Choosing the wrong one produces either stale data or unnecessary server load.

The decision framework: how often does the data change, and how personalized is it? Static config that never changes uses cache: 'force-cache'. Content that changes every few minutes uses fetch() with revalidate. User-specific data uses the default no-store. Multiple components needing the same data add React cache() on top.

io.thecodeforge.nextjs.caching-decision.tsx Β· TSX
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// ============================================
// Caching Decision Framework
// ============================================

/*
Data Type              | Changes     | Strategy
-----------------------|-------------|---------------------------
Site config            | Never       | fetch cache: 'force-cache'
Product catalog        | Hourly      | fetch next: { revalidate: 3600 }
Product prices         | Minutes     | fetch next: { revalidate: 300 }
User dashboard data    | Real-time   | fetch cache: 'no-store' (default)
Blog posts             | Daily       | Full Route Cache + revalidate: 86400
*/

import { Suspense } from 'react'
import { cache } from 'react'

export const getCategory = cache(async (slug: string) => {
  const res = await fetch(`https://api.example.com/categories/${slug}`, {
    next: { revalidate: 3600, tags: ['categories'] },
  })
  return res.json()
})

export default async function CategoryPage({ params }: { params: { category: string } }) {
  const category = await getCategory(params.category)

  return (
    <div>
      <h1>{category.name}</h1>
      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductList category={params.category} />
      </Suspense>
    </div>
  )
}

async function ProductList({ category }: { category: string }) {
  const products = await fetch(`https://api.example.com/products?category=${category}`, {
    next: { revalidate: 300, tags: ['products'] },
  }).then(r => r.json())
  return <div>{products.map((p: any) => <div key={p.id}>{p.name}</div>)}</div>
}

function ProductGridSkeleton() {
  return (
    <div className="grid grid-cols-3 gap-4">
      {Array.from({ length: 6 }).map((_, i) => (
        <div key={i} className="h-40 animate-pulse rounded bg-muted" />
      ))}
    </div>
  )
}
πŸ’‘Combine All Four Layers on Complex Pages
  • Full Route Cache for the static shell β€” opt in with cacheable fetches
  • PPR Suspense boundaries separate static from dynamic
  • fetch() with different revalidate values per data source
  • React cache() deduplicates shared data β€” category info fetched once
πŸ“Š Production Insight
Complex pages use all four layers simultaneously β€” each data source has its own cache strategy.
Rule: start with default no-store for user data, add revalidate: N for content β€” optimize from there.
🎯 Key Takeaway
All four layers compose on a single page β€” static shell, dynamic holes, per-fetch freshness, deduplication.
Rule: do not pick one layer for everything β€” each data source needs its own cache strategy.
πŸ—‚ Next.js 16 Caching Layers Compared
When each layer operates, what it caches, and how to invalidate
LayerWhen It OperatesWhat It CachesInvalidationDefault Behavior
Full Route CacheBuild timeEntire HTML + RSC payloadRedeploy, revalidate: N, revalidatePath/TagOnly if all data is cacheable
Partial PrerenderingBuild + requestStatic shell at build, dynamic holes at requestShell: same as Full Route. Holes: per-fetchEnabled by default
fetch() / Data CacheRequest timeIndividual fetch responsesrevalidate: N, revalidateTagno-store (fresh every request)
React cache()Single renderFunction return values within one requestCleared after renderNo caching β€” must wrap explicitly
Router Cache (client)BrowserRSC payloads for navigationrouter.refresh(), 30s/5min TTL30s static, 5min dynamic

🎯 Key Takeaways

  • Next.js 16 has four server caching layers plus a client Router Cache
  • fetch() defaults to no-store in Next.js 15+ β€” you must opt-in to caching with revalidate or cache: 'force-cache'
  • Partial Prerendering splits pages into static shells and dynamic streaming holes β€” dynamic APIs only affect their boundary
  • React cache() deduplicates within one render pass β€” define at module level, not inside components
  • Each data source needs its own cache strategy β€” do not apply one setting to all fetches

⚠ Common Mistakes to Avoid

    βœ•Opting into permanent caching without revalidation
    Symptom

    CMS updates are invisible on the frontend. Pages show data from the last build.

    Fix

    Use next: { revalidate: N } instead of cache: 'force-cache', or add on-demand revalidation with revalidateTag.

    βœ•Assuming fetch() caches by default
    Symptom

    Pages are slow β€” every request fetches fresh data because no caching was configured.

    Fix

    In Next.js 16, add next: { revalidate } to opt into caching. The default is no-store.

    βœ•Using dynamic APIs outside Suspense boundaries
    Symptom

    Static shell is delayed β€” the whole segment waits for dynamic data.

    Fix

    Move cookies(), headers(), and searchParams usage into Suspense-wrapped components.

    βœ•Defining React cache() inside a component
    Symptom

    Data is fetched multiple times during one render.

    Fix

    Move the cached function to lib/data.ts and wrap with cache() at the module level.

    βœ•Using fetch() caching in Client Components
    Symptom

    fetch() calls bypass Next.js caching entirely.

    Fix

    Fetch data in a Server Component and pass as props to the Client Component.

Interview Questions on This Topic

  • QWhat are the four server caching layers in Next.js 16, and when does each operate?Mid-levelReveal
    Full Route Cache operates at build time β€” stores entire HTML when all data is cacheable. Partial Prerendering operates at build + request β€” static shell cached, dynamic holes stream. Data Cache (fetch) operates at request time β€” each fetch defaults to no-store unless you opt in with revalidate. React cache() operates within a single render β€” deduplicates calls but doesn't persist.
  • QWhat is the difference between React cache() and fetch() caching?SeniorReveal
    React cache() deduplicates identical function calls within one server render β€” request-scoped, no persistence. fetch() caching controls Data Cache persistence across requests β€” defaults to no-store in Next.js 16, opt in with revalidate or force-cache. They combine: cache() prevents duplicate work in one render, fetch options control freshness across renders.
  • QHow would you debug a page that shows stale data after a CMS update?Mid-levelReveal
    1) Check fetch() options β€” if using cache: 'force-cache' without revalidate, data is permanent. 2) Check x-nextjs-cache header β€” HIT means Full Route Cache. 3) Verify revalidation β€” add next: { revalidate: 300 } or call revalidateTag() from webhook. Ensure tag strings match exactly.

Frequently Asked Questions

How do I invalidate the cache when my CMS updates content?

Use next: { revalidate: 300 } for time-based updates, plus on-demand revalidation via revalidateTag('products') called from a webhook handler at /api/revalidate.

Can I use fetch() caching in Client Components?

No. fetch() options only work in Server Components. Fetch in a Server Component and pass data as props.

What is the performance difference between Full Route Cache and SSR?

Full Route Cache serves pre-built HTML β€” TTFB <50ms. SSR with no-store fetches data each request β€” TTFB 200-500ms. PPR gives you both: instant shell, streaming dynamic parts.

πŸ”₯
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousHow to Build an AI Agent with Next.js, LangChain & SupabaseNext β†’How to Build a Design System with shadcn/ui, Tailwind & Radix
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged