Next.js 16 Caching — Stale Prices from Full Route Cache
Product prices went stale for 24 hours when Full Route Cache baked a force-cached fetch without revalidation.
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- 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
Next.js caching is a multi-layered system that determines how your pages and data are stored and served, directly impacting both performance and freshness. The Full Route Cache (Layer 1) is the most aggressive: it pre-renders entire pages at build time into static HTML, meaning every visitor gets the same pre-built file.
This is fantastic for marketing sites or blogs where content rarely changes, but it becomes a liability for dynamic data like pricing—if a product's price updates after deployment, users see stale values until the next build. This is why e-commerce sites using static generation often serve outdated prices, a classic pitfall when caching isn't tuned to data volatility.
Below the route cache, Partial Prerendering (Layer 2) lets you mix static shells with dynamic holes—your page layout is cached, but specific components (like a price ticker) fetch fresh data per request. The fetch() cache (Layer 3) gives you per-request control via next: { revalidate } or cache: 'no-store', letting you decide how long a specific API response lives.
Finally, React's function (Layer 4) deduplicates identical requests within a single render pass, preventing redundant network calls without affecting cross-request freshness. The key insight: these layers stack, and the most restrictive one wins.cache()
If your Full Route Cache is static, even a no-store fetch won't help—the page itself is already baked. For real-time prices, you need to either disable the route cache entirely (using dynamic = 'force-dynamic') or use Partial Prerendering to isolate the dynamic data.
Tools like Vercel's ISR (Incremental Static Regeneration) or on-demand revalidation can bridge the gap, but the fundamental tradeoff remains: cache aggressively for speed, but invalidate ruthlessly for accuracy.
Next.js 16 caches data and pages at multiple layers. Think of it like a shipping warehouse: Full Route Cache is the pre-packed box ready to ship (fastest, but contents only change when you rebuild or invalidate). Partial Prerendering is like a pre-built picture frame with a live video playing inside — the frame is instant, the content streams. fetch() cache decides how fresh each item should be — by default it fetches fresh every time, you opt-in to keep it. React cache() ensures you don't fetch the same item twice during one packing session. Getting these layers wrong means stale data, slow pages, or both.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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.
Why Full Route Cache Can Serve Stale Prices
Next.js 16 caching strategies define how your application stores and reuses rendered pages, data, and static assets to balance performance with freshness. The core mechanic is the Full Route Cache (FRC), which caches entire HTML pages at build time or on first request, then serves them instantly for subsequent visitors. This cache is static by default — it does not revalidate unless you explicitly configure ISR (Incremental Static Regeneration) or opt into dynamic rendering. In practice, FRC is a key-value store keyed by route path, storing the final HTML output. It operates at the edge or server level, with a default TTL of infinity for static routes. The critical property: FRC does not automatically invalidate when underlying data changes. If your product prices update in the database, the cached HTML still shows the old price until you trigger revalidation via revalidatePath, revalidateTag, or a time-based revalidate interval. Use FRC when your content changes infrequently and you need sub-millisecond response times — think marketing pages, blog posts, or documentation. It matters in real systems because it directly impacts revenue: a stale price on an e-commerce product page can lead to customer disputes, lost sales, or regulatory fines. The tradeoff is always between latency and freshness, and FRC biases heavily toward latency.
revalidate or revalidateTag for any page displaying dynamic data like prices, inventory, or user-specific content.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.
// ============================================ // 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, })) }
- 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
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.
// ============================================ // 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> // ) // }
- 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'
cookies() or searchParams only make that component dynamic, not the whole page.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.
// ============================================ // 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() without options defaults to no-store in Next.js 16 — data is fresh every request
- cache: 'force-cache' caches permanently — use for static content, add revalidation
- revalidate: N serves stale data immediately, regenerates in background — sweet spot for most content
- Tagged fetch enables group invalidation — revalidateTag('products') invalidates all tagged fetches
- fetch() caching only works in Server Components — client components use native fetch
fetch() that should be cached must have an explicit cache option.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.
// ============================================ // 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> }
- 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()
cache() deduplicates within one render pass — it is not a persistent cache.cache() inside a component creates a new cache per render — no deduplication happens.cache() is request-scoped memoization — deduplicates identical calls within one server render.cache() creates a new instance per render.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.
// ============================================ // 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> ) }
- 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
Client-Side Caching: Don't Bloat Your Server for Data the Browser Already Has
Most devs forget that caching isn't just a server concern. Every time your client re-fetches data it already holds, you burn bandwidth and CPU on both ends. That's amateur hour.
Next.js doesn't mandate a client cache — you build it. The weapon of choice is a stale-while-revalidate pattern with a simple state wrapper. SWR and TanStack Query dominate this space, but I've seen teams roll their own with 30 lines of code and zero dependencies.
The strategy: cache API responses in memory or localStorage with a timestamp. On subsequent mounts, return cached data immediately, then fire a background fetch to validate freshness. Your UI never blocks, the server gets fewer hammerings, and users see instant renders.
Where this bites you: optimistic UIs that show stale data without a refresh indicator. Always pair client caching with a subtle loading skeleton or a 'refreshing' badge. Otherwise, users edit a comment they already deleted — and that's a support ticket you don't want.
// io.thecodeforge — javascript tutorial const cache = new Map(); async function fetchWithClientCache(url, ttlMs = 30000) { const now = Date.now(); const cached = cache.get(url); if (cached && now - cached.timestamp < ttlMs) { // Fire background revalidation fetch(url) .then(r => r.json()) .then(data => cache.set(url, { data, timestamp: Date.now() })) .catch(() => {}); // silent fail — stale is fine return cached.data; } const res = await fetch(url); const data = await res.json(); cache.set(url, { data, timestamp: now }); return data; } // Usage in component const userOrders = await fetchWithClientCache('/api/orders', 60000);
API Caching: Why Your /api/products Endpoint Is Your Slowest Dependency
You've got a Next.js API route that queries a database and joins four tables. It takes 450ms cold. Multiply that by 10,000 requests per hour, and you're either paying for over-provisioned database connections or piling request latency.
The fix: HTTP caching at the route level. Next.js API routes don't automatically cache — you have to opt in. Use the Cache-Control response header to tell CDNs and browsers how long they can keep your response. For public, mostly-static data (e.g., product catalog, store hours), set s-maxage=3600, stale-while-revalidate=120.
stale-while-revalidate is your best friend here. It tells the CDN: serve the stale copy for up to 120 seconds while you revalidate in the background. Users never see a loading spinner, and your origin server gets fewer requests.
Where this breaks: authenticated endpoints. Never cache responses containing user-specific data at the CDN level. Use private, no-cache for those. Otherwise, Alice logs out and sees Bob's checkout page — GDPR violation speedrun.
// io.thecodeforge — javascript tutorial export async function GET(request) { const products = await fetchProductsFromDB(); // 450ms cold return new Response(JSON.stringify(products), { status: 200, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=120' } }); } // Vercel Edge or Node.js runtime — both respect Cache-Control // For ISR-like behavior: pair with revalidate in next.config.js
?fresh=true query param override. In your API route, check for it. If present, bypass cache and force a fresh fetch. This lets you manually purge cache during deploys without waiting for TTL.How Next.js Optimizes Caching Out of the Box – And When to Shut It Off
Next.js isn't just a framework; it's a caching appliance that defaults to aggressive, build-time, and fetch-level caching. Your GET request to https://api.example.com/prices? By default, the response is cached in the Full Route Cache and the Data Cache simultaneously. The WHY is performance: serve static HTML from edge nodes, zero server cold starts. The HOW is automatic: any inside a static page gets its result cached indefinitely. This is great for your landing page, catastrophic for your pricing page if prices change every 30 seconds. You must understand these defaults before you can safely override them. Production teams waste days debugging stale data because they never read the docs on fetch()fetch options like cache: 'no-store' or next: { revalidate: 30 }. The framework is not magic; it's a set of sensible defaults that become dangerous when you assume they don't exist. Inspect the Cache-Control headers on your deployment immediately.
// io.thecodeforge — javascript tutorial // See what Next.js does to a simple fetch by default async function getPricing() { // No options? Next.js caches this in the Data Cache. const res = await fetch('https://api.example.com/prices'); return res.json(); } // Force it to be fresh per request async function getFreshPricing() { const res = await fetch('https://api.example.com/prices', { cache: 'no-store' }); return res.json(); } export default async function Page() { const prices = await getFreshPricing(); return <div>{prices.amount}</div>; }
Key Takeaways and Best Practices – Cache Less Than You Think
Most caching failures happen because teams cache too much, too early. Before adding a cache, ask: does this request change rarely, or is it shared across many users without personalization? If neither, skip the cache. The cost of stale data and invalidation complexity often outweighs the performance gain.
A common pitfall is caching API responses at the page level, then breaking personalization or authentication. Instead, isolate caching to the specific data layer — cache database query results with a short TTL, not the entire rendered page. Use Next.js stale-while-revalidate for fresh background fetches without blocking the UI.
Always start with no cache. Profile actual latency. Only add caching when you can measure a real bottleneck. And when you do cache, always set an explicit time-to-live (TTL) — never allow unbounded caches in production. Prefer short TTLs (seconds, not hours) and rely on fast origin requests rather than serving stale content.
// next.js — static fetch with stale-while-revalidate export async function getServerSideProps(context) { const { userId } = context.query; const res = await fetch(`https://api.example.com/user/${userId}`, { headers: { 'Cache-Control': 's-maxage=10, stale-while-revalidate=60' }, }); const user = await res.json(); return { props: { user } }; }
The `use cache` Directive (Next.js 15+)
Next.js 15 introduces use cache as the declarative way to control caching behavior at the component and function level. Unlike the older unstable_cache, use cache is stable, type-safe, and integrates directly with React's server components. You place it at the top of an async function or component to mark it for caching. The directive tells Next.js to cache the result after first execution, then serve the cached version for subsequent requests within the cache duration. Combine it with cacheLife to set precise expiration times. This replaces manual cache key management and reduces boilerplate. Use use cache when you need per-function caching without polluting your data layer. It's especially powerful for database queries and API calls that return identical results across requests. The main downside: you lose fine-grained control over cache invalidation that unstable_cache offered via tags. But for most use cases, the simplicity wins.
// io.thecodeforge — javascript tutorial import { cacheLife } from 'next/cache' export default async function ProductGrid() { 'use cache' cacheLife('hours', 1) const products = await db.query('SELECT * FROM products LIMIT 20') return <ProductList items={products} /> }
use cache only works in Server Components. Placing it in a Client Component throws a build error. Always verify your file extension matches .tsx or .jsx with the 'use server' directive absent.use cache for component-level caching — it replaces manual caching wrappers with a single directive.A Practical Mental Model for Next.js Caching
Stop thinking of Next.js caching as a multi-layer puzzle. Instead, see it as a hierarchy of time-to-live decisions. At the top: Full Route Cache caches entire pages at build time — great for blogs, terrible for inventory. Below that: fetch() cache controls individual network requests per route segment. At the bottom: React cache() deduplicates identical requests within a single render pass. The key rule: every layer is optional and independent. Disable layers you don't need using revalidate, dynamic, or no-store. For dynamic apps like dashboards, set dynamic = 'force-dynamic' on the page and cache specific data fetching functions with use cache. For static sites, let Full Route Cache work and only opt out for user-specific content. Memory model: think of caching as a stack of CDs. Full Route Cache is the album cover — fast but outdated. fetch() cache is a song you skip. React cache() is the needle reading the track twice, then remembering.
// io.thecodeforge — javascript tutorial export const dynamic = 'force-dynamic' export default async function Dashboard({ userId }) { const data = await fetch('https://api.example.com/user/' + userId, { cache: 'no-store' }) return <UserProfile data={await data.json()} /> }
dynamic = 'force-dynamic' on a layout makes every nested route dynamic — killing Full Route Cache globally.Product prices stale for 24 hours due to Full Route Cache
fetch() would stay fresh by default in Next.js 16, but they had explicitly opted into permanent caching with cache: 'force-cache' for performance.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.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.- 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 revalidation
- CMS-driven content needs webhook-triggered revalidation — do not rely on time-based revalidation alone
fetch() or trigger on-demand revalidation via revalidatePath().fetch() (no-store) or cookies() call is forcing the segment to stream — with PPR only that Suspense boundary streams, not the whole page.cache() — it deduplicates identical calls within a single render pass.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-cachefetch() or call revalidatePath() from a webhooknext build 2>&1 | grep '○\|●\|ƒ'cat .next/routes-manifest.json | jq '.staticRoutes | length'grep -rn 'cache(' src/lib/ --include='*.ts' | head -10cat src/app/page.tsx | grep -B 2 -A 5 'cache('cache() at the top levelcat src/app/api/revalidate/route.tscurl -X POST https://your-site.com/api/revalidate -H 'Authorization: Bearer TOKEN' -d '{"tag":"products"}'| Layer | When It Operates | What It Caches | Invalidation | Default Behavior |
|---|---|---|---|---|
| Full Route Cache | Build time | Entire HTML + RSC payload | Redeploy, revalidate: N, revalidatePath/Tag | Only if all data is cacheable |
| Partial Prerendering | Build + request | Static shell at build, dynamic holes at request | Shell: same as Full Route. Holes: per-fetch | Enabled by default |
| fetch() / Data Cache | Request time | Individual fetch responses | revalidate: N, revalidateTag | no-store (fresh every request) |
React cache() | Single render | Function return values within one request | Cleared after render | No caching — must wrap explicitly |
| Router Cache (client) | Browser | RSC payloads for navigation | router.refresh(), 30s/5min TTL | 30s static, 5min dynamic |
| File | Command / Code | Purpose |
|---|---|---|
| io.thecodeforge.nextjs.full-route-cache.tsx | interface Product { | Layer 1 |
| io.thecodeforge.nextjs.partial-prerendering.tsx | function DashboardHeader() { | Layer 2 |
| io.thecodeforge.nextjs.fetch-cache.tsx | async function getUserSession(token: string) { | Layer 3 |
| io.thecodeforge.nextjs.react-cache.tsx | export const getUser = cache(async (userId: string) => { | Layer 4 |
| io.thecodeforge.nextjs.caching-decision.tsx | /* | Choosing the Right Caching Strategy |
| StaleWhileRevalidate.js | const cache = new Map(); | Client-Side Caching |
| APIRouteCache.js | export async function GET(request) { | API Caching |
| checkDefaults.js | async function getPricing() { | How Next.js Optimizes Caching Out of the Box – And When to S |
| stale-while-revalidate.js | export async function getServerSideProps(context) { | Key Takeaways and Best Practices – Cache Less Than You Think |
| useCacheExample.js | export default async function ProductGrid() { | The `use cache` Directive (Next.js 15+) |
| cacheModel.js | export const dynamic = 'force-dynamic' | A Practical Mental Model for Next.js Caching |
Key takeaways
cache() deduplicates within one render passCommon mistakes to avoid
5 patternsOpting into permanent caching without revalidation
Assuming fetch() caches by default
Using dynamic APIs outside Suspense boundaries
cookies(), headers(), and searchParams usage into Suspense-wrapped components.Defining React cache() inside a component
cache() at the module level.Using fetch() caching in Client Components
Interview Questions on This Topic
What are the four server caching layers in Next.js 16, and when does each operate?
cache() operates within a single render — deduplicates calls but doesn't persist.What is the difference between React cache() and fetch() caching?
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.How would you debug a page that shows stale data after a CMS update?
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
Use next: { revalidate: 300 } for time-based updates, plus on-demand revalidation via revalidateTag('products') called from a webhook handler at /api/revalidate.
No. fetch() options only work in Server Components. Fetch in a Server Component and pass data as props.
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.
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
That's Next.js. Mark it forged?
7 min read · try the examples if you haven't