Next.js Caching in 2026: From the Four-Layer Model to use cache
A beginner-to-advanced guide to Next.js caching: the four cache layers, the RSC payload, Cache Components, and when revalidateTag beats updateTag.
By Naga Sai Rao··35 min read
This guide explains how caching and invalidation work in the Next.js App Router, from "what even gets cached" up to the Next.js 16 Cache Components model, with the data flow drawn out so you can always answer where a copy lives and when it dies. It is written for someone preparing for technical interviews who wants a mental model that survives contact with a real codebase, and it reflects the current state of Next.js as of mid-2026 (Next.js 16, with the classic four-layer model still in wide use on Next.js 14 and 15 apps). By the end you should be able to look at any page and say which cache serves it, how stale it can get, and exactly how to bust it.
Note. Next.js caching has two distinct eras, and mixing them up is the number one source of confusion. The "classic" four-layer model (Next.js 13 to 15) caches implicitly and you opt out. The new Cache Components model (Next.js 16, opt-in) caches explicitly and you opt in. This guide covers both, and flags which is which at every step.
The one mental model: four copies between your database and the screen
Every caching question becomes simple if you picture the journey a piece of data takes from your database to the pixels on a user's screen. Along that road there are four places Next.js can keep a copy, and caching is nothing more than choosing how far down the road to store that copy and when to throw it away. The closer a copy sits to the user, the faster the response and the staler the data risks being.
Analogy. Think of a grocery supply chain. The origin is the farm (your database, CMS, or API). The Data Cache is the regional warehouse: one stocked copy serves every store. The Full Route Cache is the pre-made meal already plated and sitting on the shelf (fully rendered HTML). The Router Cache is the food already in the customer's own fridge at home (the browser). Request Memoization is a sticky note you keep for a single shopping trip so you do not grab the same item twice. The farther the food is from the farm, the faster you eat, but the more likely it is yesterday's batch.
CSR, SSR, SSG, ISR, and PPR explained with realistic examples, plus exactly which Core Web Vitals each rendering strategy moves. From build time to the browser.
Until the route's data is revalidated or you redeploy
Router Cache
Browser, in memory
RSC payload of routes the user has visited
The browser session (with a short staleness window)
Here, RSC stands for React Server Component, the server-rendered component format whose serialized output Next.js ships to the browser. Keep the four-copy picture in mind; every section below is a zoom into one stop on this road.
Two eras: implicit caching versus explicit caching
Before any code, you must know which era an app is in, because the defaults are opposite.
In Next.js 14, fetch() calls in Server Components were cached by default. You got speed for free and had to opt out with cache: 'no-store', which surprised people with stale data. In Next.js 15, that default was reversed: fetches are not cached unless you opt in, and the same applied to GET Route Handlers. In Next.js 16, the new Cache Components model goes all the way to explicit: data is dynamic by default and you mark what to cache with a use cache directive.
Version
Default for fetch()
How you control caching
Mental model
Next.js 14
Cached by default
Opt out with no-store, dynamic
Implicit, cache-first
Next.js 15
Not cached by default
Opt in with force-cache, next.revalidate
Implicit, dynamic-first
Next.js 16 (Cache Components)
Dynamic by default
Opt in with use cache, cacheLife, cacheTag
Explicit, you place the caches
Beginner trap. Any tutorial written before late 2024 assumes "fetch is cached by default." On Next.js 15 and 16 that is wrong, and following it will leave you wondering why your data is fresh when you expected it cached, or why you see extra database load after upgrading. Always check which Next.js version a guide targets.
Interview answer: "Next.js 14 cached fetches by default and you opted out; Next.js 15 flipped that so fetches are dynamic unless you opt in; Next.js 16's Cache Components model makes caching fully explicit with the use cache directive. So the first question I ask about any caching bug is which version and which model the app uses, because the defaults are opposite."
Following one request: where data is cached, step by step
Data flows one way (origin to screen), but on an incoming request the caches are checked in the reverse order: the copy closest to the user is consulted first. Tracing both directions once makes everything click.
text
DATA FLOW (writes a copy at each stop, left to right)
origin DB ──► Data Cache ──► Full Route Cache ──► Router Cache ──► screen
(server) (server) (server) (browser)
REQUEST FLOW (reads, checked closest-to-user first)
navigation ──► Router Cache hit? ──yes──► render instantly, no server hit
│ no
▼
Full Route Cache hit? ──yes──► serve stored HTML/RSC
│ no (dynamic route)
▼
render on server
│ during render, each fetch:
▼
Data Cache hit? ──yes──► use stored data
│ no
▼
call origin, store in Data Cache
(Request Memoization dedupes repeats within this one render)
Gotcha. Because the Router Cache sits closest to the user and is checked first, a route can fetch perfectly fresh data on the server and the user still sees an old screen, because their browser served the visit from the Router Cache without ever hitting your server. This single fact explains most "I revalidated but nothing changed" reports, and we return to it below.
A concrete example for each cache: one news site, four jobs
Enough abstraction. Picture a news website called NovaNews. It has a homepage with a list of headlines, individual article pages, a plain "About" page, and a live election-results widget. Thousands of people read it at the same time. Watch the same four caches each do a different job for this one site.
Picture this (Request Memoization): do not fetch the same article twice in one render. The homepage shows the lead story in the big hero banner and again lower down in a "Don't miss" rail. Both components ask for article number 1. Without memoization that is two identical fetches for one page; with it, Next.js fetches once and shares the result.
jsx
async function getArticle(id) {
const res = await fetch(`https://cms.novanews.com/articles/${id}`)
return res.json()
}
// The hero and the "Don't miss" rail both call getArticle(1) during the same render.
// Request Memoization makes that ONE request, not two.
What it saves: duplicate work inside a single page render. Scope: this one render, then gone.
Picture this (Data Cache): fetch the headlines once for 50,000 readers. At 9am, 50,000 people load the homepage in a minute. You do not want to ask the CMS for the headline list 50,000 times. Fetch it once, keep it in the server warehouse, and serve everyone from there; refresh every 60 seconds so it stays current.
jsx
// One server-side copy serves every reader; refreshed at most once a minute.
const headlines = await fetch('https://cms.novanews.com/headlines', {
next: { revalidate: 60, tags: ['headlines'] },
}).then((r) => r.json())
What it saves: hammering your database or CMS. Scope: all readers, across requests, until revalidated.
Picture this (Full Route Cache): render the About page once, not per visitor. The "About" page is identical for everyone and changes maybe twice a year. There is no reason to rebuild it per request. Next.js renders it to HTML once and serves that same file to every visitor.
jsx
// No cookies, no search params, no per-request data, so the route is static.
// Rendered once, stored as HTML and RSC, served to all.
export default async function AboutPage() {
const about = await fetch('https://cms.novanews.com/about', { next: { revalidate: 86400 } })
return <About {...(await about.json())} />
}
What it saves: re-rendering an identical page. Scope: everyone, until the data is revalidated or you redeploy.
Picture this (Router Cache): the Back button is instant. A reader opens an article, reads it, and taps Back. The homepage appears immediately because their own browser kept a copy from a moment ago; there is no call to your server at all. What it saves: a network round-trip for pages the reader already saw. Scope: that one reader's browser, for the session.
One sentence to hold it together: Request Memoization works within a single render, the Data Cache and Full Route Cache are shared copies on your server, and the Router Cache is a private copy inside each reader's browser. Now we zoom into each one.
Layer 1: Request Memoization
During a single server render, if several components call fetch() with the same URL and options, Next.js makes the network request once and hands the same result to all of them. It exists so components can each fetch what they need without prop-drilling, and it disappears the instant the render finishes.
Analogy. It is the sticky note for one shopping trip. If three family members all ask you to grab milk on the same run, you buy one carton, not three. When you get home, the note is thrown away.
jsx
// Both components call the same fetch during one render.
// Request Memoization makes only ONE actual HTTP request.
async function getUser(id) {
const res = await fetch(`https://api.example.com/users/${id}`) // memoized within this render
return res.json()
}
async function Avatar({ id }) {
const user = await getUser(id) // first call: real request
return <img src={user.avatar} alt="" />
}
async function Greeting({ id }) {
const user = await getUser(id) // same render: returns the memoized result, no new request
return <h1>Hi {user.name}</h1>
}
Beginner trap. Request Memoization only patches fetch(), and only for GET. If you query a database or ORM directly, it is not deduplicated. Wrap those calls in React's cache() to get the same per-render dedupe.
jsx
import { cache } from 'react'
import { db } from '@/lib/db'
// React's cache() dedupes non-fetch work (DB/ORM calls) within a single render.
export const getProduct = cache(async (id) => {
return db.product.findUnique({ where: { id } })
})
Interview answer: "Request Memoization dedupes identical fetches within one render pass so I can fetch where I need data instead of lifting it up. It is automatic, ephemeral, and only covers fetch. For database or ORM calls I wrap the function in React's cache() to get the same single-flight behaviour."
Layer 2: The Data Cache
The Data Cache is the persistent, server-side store for the results of data fetches. Unlike memoization, it survives across requests, across users, and across deployments, so the origin (your database or upstream API) is hit far less often. This is the warehouse.
Analogy. One stocked warehouse serves every store in the region. Customers never go back to the farm; they get the warehoused copy until it is restocked (revalidated).
On Next.js 15 you opt in per fetch:
jsx
// Cache indefinitely until explicitly revalidated:
await fetch('https://api.example.com/config', { cache: 'force-cache' })
// Cache but refresh in the background at most every 60 seconds (time-based revalidation):
await fetch('https://api.example.com/prices', { next: { revalidate: 60 } })
// Tag the entry so you can bust it on demand later:
await fetch('https://api.example.com/posts', { next: { tags: ['posts'] } })
// Never cache (always hit origin):
await fetch('https://api.example.com/cart', { cache: 'no-store' })
For data that does not come through fetch (a database query, an SDK call, a heavy computation), wrap it with unstable_cache, which still exists in Next.js 16 despite the name:
jsx
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'
export const getTopProducts = unstable_cache(
async (orgId) => db.product.findMany({ where: { orgId }, orderBy: { sales: 'desc' } }),
['top-products'], // key parts
{ tags: ['products'], revalidate: 3600 }, // bust by tag, or after 1 hour
)
Note. Time-based revalidation here is ISR (Incremental Static Regeneration): a stale copy is served while a fresh one is generated in the background, then swapped in. It is the same idea the Pages Router had, now expressed through cache options.
Interview answer: "The Data Cache persists fetch results on the server across requests and deployments. I control it with cache: 'force-cache', next.revalidate for time-based refresh, and next.tags for on-demand busting. For non-fetch work like ORM queries I use unstable_cache, which takes a key, tags, and a revalidate interval."
Layer 3: The Full Route Cache
When a route has no request-specific data, Next.js can render it once and store the finished HTML plus the RSC payload, then serve that to everyone. This is the pre-plated meal on the shelf: no cooking per customer.
The key question is whether a route is static (cacheable) or dynamic (rendered per request). A route becomes dynamic the moment it reads request-specific input: calling cookies() or headers(), reading searchParams, doing a cache: 'no-store' fetch, or setting export const dynamic = 'force-dynamic'.
jsx
// STATIC: no request data -> rendered once, stored in the Full Route Cache, served to all.
export default async function MarketingPage() {
const data = await fetch('https://cms.example.com/home', { next: { revalidate: 86400 } })
return <Hero {...await data.json()} />
}
// DYNAMIC: reads cookies -> route opts out of the Full Route Cache, renders per request.
import { cookies } from 'next/headers'
export default async function Dashboard() {
const session = (await cookies()).get('session') // request-specific input
return <Private session={session} />
}
Gotcha. Dynamic is contagious upward. If a layout shared by many pages calls cookies(), every page under that layout becomes dynamic, even pages that looked perfectly static on their own. A route that was fast in development can quietly turn dynamic in production because someone added one cookies() call to a parent layout.
Interview answer: "The Full Route Cache stores the rendered HTML and RSC payload for static routes so they are served without re-rendering. A route stays static until it reads request data, cookies, headers, search params, a no-store fetch, or force-dynamic, at which point it becomes dynamic and is rendered per request. And dynamic propagates from layouts down to their pages."
What "RSC payload" really means
The section keeps saying the Full Route Cache stores "HTML plus the RSC payload," so let us make that second half concrete, because it is the part people only ever see named, never explained. RSC is React Server Component, and the RSC payload is the result of running your Server Components on the server and serializing the React tree they produced into a compact, line-based stream (React calls the format Flight). It is not HTML and not plain JSON. It is the UI described as data: element types, props, text, and holes where Client Components go, plus references to the JavaScript that fills those holes.
The flip to remember: a Server Component ships its output as data and none of its code; a Client Component ships a reference to its code, which the browser downloads and runs. Take one small page:
jsx
// A Server Component that renders a Client Component (the LikeButton).
import LikeButton from './like-button' // 'use client' lives in that file
export default async function Page() {
const post = await getPost() // server-only DB call; never reaches the browser
return (
<article>
<h1>{post.title}</h1>
<LikeButton postId={post.id} initialLikes={post.likes} />
</article>
)
}
The RSC payload it produces, simplified to the parts that matter, looks like this:
Reading it: in row 0, ["$", "article", ...] encodes a React element, where "$" is the marker "this is an element" and "article" and "h1" are plain strings, meaning the Server Component was rendered all the way down to host tags with its text ("Hello RSC") baked in. Where <LikeButton> sat, there is no button code; there is a hole, "$L1", a lazy reference to row 1, carrying the serialized props {"postId":42,"initialLikes":7}. Row 1, the I[...] import reference, tells the client which JS chunk and export to load for that Client Component. So the same tree holds the Server Component as finished data and the Client Component as a pointer to code.
Note. On first load, the route ships three things with three jobs: the HTML for an instant non-interactive preview, the RSC payload to reconcile the server and client trees, and the client JavaScript that hydrates the holes (here, wiring up the LikeButton's onClick). The Full Route Cache stores the first two together; the client JS is loaded via those I[...] references. For client-side navigations Next.js fetches just the RSC payload (a request with an RSC: 1 header, response type text/x-component), which is why a cached route navigates instantly without re-rendering on the server.
Gotcha. The RSC payload is an internal format, intentionally not a stable public contract; its exact wrapper rows and markers shift between React and Next.js versions. Read it to debug and to understand what is cached, but never parse or depend on it in application code.
Layer 4: The client-side Router Cache
The Router Cache lives in the browser's memory and stores the RSC payload of routes the user has already visited (and routes Next.js prefetched from <Link> components in view). It makes back-and-forward and link navigations feel instant, because the client does not re-ask the server.
Analogy. It is the food already in the user's own fridge at home. They do not drive to the warehouse for something they grabbed an hour ago; they open the fridge.
This is the cache that surprises people, for three reasons. Its staleness window historically defaulted to around 30 seconds for dynamic routes and 5 minutes for static ones, tunable through the staleTimes config (Next.js 15 set the dynamic default to 0). It does not respect cache: 'no-store', because no-store controls the server Data Cache, not the browser. And a server-side revalidation does not reach it until the next navigation or refresh.
jsx
'use client'
import { useRouter } from 'next/navigation'
export function SaveButton() {
const router = useRouter()
async function onSave() {
await fetch('/api/save', { method: 'POST' })
router.refresh() // clears the Router Cache for the current route and refetches from the server
}
return <button onClick={onSave}>Save</button>
}
Gotcha. "I called revalidateTag but the user still sees old data." Tag and path revalidation clear the server caches (Data Cache and Full Route Cache); the browser's Router Cache only updates on the next navigation, a Server Action response, or a router.refresh(). The fix is usually to trigger one of those after the mutation.
How the four layers interact: invalidation flows outward
The layers are not independent; busting one cascades toward the user, but only so far on its own.
text
revalidate the DATA CACHE for a fetch/tag
│ forces regeneration of...
▼
the FULL ROUTE CACHE entry that depended on it
│ which is picked up by the browser...
▼
only on the NEXT navigation / Server Action / router.refresh()
│
▼
ROUTER CACHE in the browser updates ──► user finally sees fresh data
The practical rule: server revalidation reaches the server caches immediately (or on next visit, depending on the API), but the browser Router Cache always needs a navigation event to catch up. Keeping these four as distinct boxes in your head resolves the large majority of Next.js caching bugs.
The new model: Cache Components and use cache (Next.js 16)
Next.js 16 introduces Cache Components, which flips the philosophy: nothing is cached implicitly, and you opt in by marking code as cacheable. You enable it once, then reach for three new tools.
ts
// next.config.ts (top-level in Next.js 16.1+; it was experimental in 16.0)
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { cacheComponents: true }
export default nextConfig
The use cache directive marks a file, a function, or a component as cacheable; the compiler derives a cache key from the inputs. It is the explicit replacement for unstable_cache.
jsx
import { cacheLife, cacheTag } from 'next/cache'
// Function-level: cache this data function's result.
async function getPost(slug) {
'use cache'
cacheLife('hours') // how long it stays fresh (see profiles below)
cacheTag(`post-${slug}`) // label it for targeted invalidation
return db.post.findUnique({ where: { slug } })
}
cacheLife sets the lifetime, either by a named profile or an object. The three numbers mean distinct things, and getting them straight is an interview favourite. SWR below stands for stale-while-revalidate: serve the cached copy immediately while fetching a fresh one in the background.
Field
Meaning
stale
How long the client may serve the value without checking the server
revalidate
How long until the server refreshes it in the background (SWR)
expire
Hard maximum; after this, the next read blocks until fresh data arrives
jsx
// Named preset (built-ins include seconds, minutes, hours, days, weeks, max):
cacheLife('days')
// Or a custom shape:
cacheLife({ stale: 3600, revalidate: 7200, expire: 86400 }) // 1h / 2h / 1 day
Cache Components also makes PPR (Partial Prerendering) the default: Next.js prerenders a static shell instantly and streams the dynamic parts into it, so one page can be partly cached and partly live. You create a dynamic "hole" by wrapping the live part in <Suspense>.
jsx
import { Suspense } from 'react'
export default function ProductPage({ params }) {
return (
<>
<ProductDetails slug={params.slug} /> {/* cached, in the static shell */}
<Suspense fallback={<PriceSkeleton />}>
<LivePrice slug={params.slug} /> {/* dynamic hole, streamed in fresh */}
</Suspense>
</>
)
}
Gotcha (three that bite everyone). First, use cache does nothing without cacheComponents: true; the directive silently no-ops and your pages stay dynamic. Second, you cannot read cookies() or headers() inside a default use cache scope; read them outside and pass the values in as arguments, or use the use cache: private variant for per-user caching. Third, Date.now(), new Date(), and performance.now() inside a prerendered cached scope throw, because a prerender has no single "now"; compute time outside the cached function.
Interview answer: "Cache Components in Next.js 16 make caching explicit. I enable cacheComponents, then mark cacheable code with use cache, set lifetime with cacheLife using stale, revalidate, and expire, and tag entries with cacheTag for invalidation. Partial Prerendering ships a static shell and streams dynamic holes wrapped in Suspense, so a single route mixes cached and live content."
Time versus on-demand: the invalidation toolkit
There are two ways to keep cached data fresh: let it expire on a timer, or bust it explicitly when something changes. On-demand invalidation has several functions that look similar and behave differently, which is exactly what interviewers probe.
Function
What it does
Where you call it
Use it for
next.revalidate / cacheLife
Time-based refresh (ISR / SWR)
In the fetch or use cache scope
Content that can tolerate being a bit stale
revalidateTag(tag, 'max')
Mark tagged data stale, refresh in background (SWR)
Server Action or Route Handler
Background updates: CMS publish, scheduled sync
updateTag(tag)
Expire tagged data now and block for fresh data
Server Actions only
Read-your-own-writes after a user mutation
revalidatePath(path)
Invalidate all cached data for a route path
Server Action or Route Handler
When you do not know the tags for a route
router.refresh()
Refetch the current route from the server
Client component
Push fresh server data to the browser after an action
The revalidateTag versus updateTag distinction is the sharpest one in Next.js 16:
jsx
// updateTag: the user just acted and must see their change immediately.
'use server'
import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData) {
const post = await db.post.create({ data: { title: formData.get('title') } })
updateTag('posts') // expire now, block for fresh data -> no stale flash
redirect(`/posts/${post.id}`)
}
jsx
// revalidateTag with 'max': a background source changed; brief staleness is fine.
'use server'
import { revalidateTag } from 'next/cache'
export async function onCmsWebhook() {
revalidateTag('posts', 'max') // serve stale instantly, refresh in the background
}
Beginner trap. The single-argument revalidateTag('posts') is now deprecated and warns in the console, because its old "expire immediately" behaviour was a footgun. Be explicit: use revalidateTag(tag, 'max') for background refresh, updateTag(tag) inside a Server Action for read-your-writes, or revalidateTag(tag, { expire: 0 }) from a Route Handler (for example a webhook) that truly needs immediate expiry.
Interview answer: "Use revalidateTag(tag, 'max') for background, stale-while-revalidate updates like a CMS publish, and updateTag(tag) inside a Server Action when the user performed the change and must see it immediately, because updateTag blocks for fresh data. revalidatePath busts a whole route when I do not know its tags, and router.refresh() pushes the new server data into the browser's Router Cache."
Real-life recipes: where and how to cache each kind of page
This is the section interviewers (and real projects) care about most: matching a strategy to the content. The rule behind all of them: cache as close to the user as the content's freshness allows, tag precisely, and bust on mutation.
Page type
Strategy
How to invalidate
Marketing / landing
Fully static, long life
On deploy, or a slow timer
Blog / docs
Static, tagged
revalidateTag('posts', 'max') on CMS publish
Product detail
Cache data for hours; price live
updateTag on price edit; Suspense for live price
Product listing + stock
Short cache or tagged
revalidateTag('inventory', 'max') on stock webhook
User dashboard (auth)
Page dynamic; cache shared data
Tag per user; updateTag on user action
Social feed
Short-lived or dynamic
updateTag after the user posts
Search results
Dynamic (depends on query)
Not cached; optionally cache popular queries
Profile settings
Dynamic page; cached data fn
updateTag(\profile-\${id}) on save
Real-time (prices, scores)
No cache
no-store, or cacheLife('seconds')
External images
Image Optimization cache
minimumCacheTTL, remotePatterns
A few worked examples make the table concrete.
Marketing page that almost never changes
jsx
// Static and long-lived. Rebuilds on deploy; refreshes at most daily otherwise.
export default async function Home() {
'use cache'
cacheLife('days')
const content = await cms.getHomepage()
return <Landing {...content} />
}
Blog that updates when an editor publishes
jsx
// 1) Cache each post and tag it.
async function getPost(slug) {
'use cache'
cacheTag(`post-${slug}`, 'blog')
return cms.getPost(slug)
}
// 2) The CMS webhook hits a Route Handler that busts the tag in the background.
import { revalidateTag } from 'next/cache'
export async function POST(req) {
const { slug } = await req.json()
revalidateTag(`post-${slug}`, 'max') // readers tolerate a few seconds of staleness
return Response.json({ revalidated: true })
}
The page itself reads the session, so it must stay dynamic. Do not try to cache the whole page; cache the expensive, shareable data functions and let the page compose them.
jsx
import { cookies } from 'next/headers'
export default async function Dashboard() {
const userId = await getUserId(await cookies()) // dynamic: per-user
return (
<>
<Header userId={userId} />
{/* The heavy widget's data is cached and tagged per user. */}
<UsageWidget userId={userId} />
</>
)
}
async function getUsage(userId) {
'use cache'
cacheTag(`usage-${userId}`) // bust just this user's data when it changes
cacheLife('minutes')
return analytics.usageFor(userId)
}
Social feed where the user must see their own post
This is the read-your-own-writes case, the textbook reason updateTag exists.
jsx
'use server'
import { updateTag } from 'next/cache'
export async function postStatus(text) {
await db.status.create({ data: { text, userId: await currentUserId() } })
updateTag(`feed-${await currentUserId()}`) // block for fresh data so they see it instantly
}
Rendering and caching external images
When you pull an image from an external URL (a user avatar from a provider, a photo from a headless CMS), use next/image. Next.js optimizes the image and stores the optimized result in its Image Optimization cache, so the heavy resize-and-encode work happens once. You must first allow the remote host in config, which is also the security boundary that stops your app optimizing arbitrary URLs.
ts
// next.config.ts -- allow specific external image hosts.
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.example.com' },
{ protocol: 'https', hostname: 'avatars.githubusercontent.com' },
],
minimumCacheTTL: 86400, // keep each optimized image at least a day
},
}
export default nextConfig
jsx
import Image from 'next/image'
// Next pulls the remote URL, optimizes it once, and serves the cached optimized copy.
export function Avatar({ src }) {
return <Image src={src} alt="" width={64} height={64} />
}
Gotcha. If you see "hostname is not configured under images" you forgot to add the host to remotePatterns. And a remote image that changes at the same URL can serve stale from the optimization cache until minimumCacheTTL passes; version the URL (a query string or content hash) when the image content can change in place.
Interview answer: "I match the cache to the content's freshness. Marketing pages are static with a long life; blogs are static and tagged so a CMS webhook busts them with revalidateTag('...', 'max'); auth dashboards stay dynamic but cache their expensive data functions tagged per user; feeds use updateTag so a user sees their own post immediately; real-time data is not cached. For external images I use next/image with remotePatterns, which optimizes once and serves from the image cache."
What plays better where (and what breaks if you pick wrong)
Picking a caching strategy is really one decision repeated: how fresh must this data be, and who is it for? Get that right and the strategy falls out. The fastest way to learn it is to see the tempting wrong choice and what it breaks, because that is what an interviewer probes and what bites you in production.
Walk these five questions in order; the first "yes" usually picks your strategy.
text
1. Same for every user AND rarely changes? -> static / long use cache
2. Same for everyone but updates sometimes? -> cache + tag, bust on change
3. Did THIS user just change it and expect to see it? -> updateTag in a Server Action
4. Is it private to one user? -> dynamic page; cache pieces per user
5. Is it real-time (price, score, seats)? -> do not cache
Same for everyone, rarely changes (marketing, About, docs)
Plays best: static Full Route Cache, or use cache with a long cacheLife. It renders once and serves instantly everywhere.
Beginner trap. The tempting mistake is adding cache: 'no-store' or force-dynamic "to be safe." That re-renders identical HTML on every single request, raising server cost and slowing the first byte, for zero benefit. Safe does not mean dynamic.
Same for everyone, updates sometimes (blog, catalog, headlines)
Plays best: cache and tag the data, then bust the tag when content changes with revalidateTag('posts', 'max'). Readers get instant pages and see updates seconds after you publish.
Beginner trap. Reaching for a tight timer like revalidate: 10. You still serve stale content for up to 10 seconds after publishing, and you refetch constantly even when nothing changed. Tags fire only when the content changes, which is both fresher and cheaper.
The user just changed it and must see it (post a comment, edit profile, place an order)
Plays best: updateTag inside the Server Action. It expires the data and blocks for the fresh copy, so the user sees their own change with no stale flash.
Gotcha. Using revalidateTag(tag, 'max') here. Its stale-while-revalidate behaviour serves the user their OLD data first, producing the classic "I saved it but nothing changed" bug. Read-your-own-writes is exactly what updateTag is for.
Private to one user (dashboard, cart, account)
Plays best: keep the page dynamic (it reads the session) and cache only the expensive shared pieces, tagged per user (for example a usage-<id> tag).
Gotcha. Caching the whole page in a shared cache to make it fast. Now user A can be served user B's page. This is not merely stale data; it is a cross-user data-leak security bug. Per-user data either stays dynamic or uses a private, per-user cache scope.
Real-time (stock price, live score, seat availability)
Plays best: do not cache. Fetch dynamically with no-store, or cacheLife('seconds') if a tiny delay is acceptable.
Gotcha. Caching for minutes to cut load. Users then act on stale numbers: they oversell the last seat or check out at yesterday's price. When a wrong number causes a wrong action, do not cache it.
Mostly static with one live part (product page with a live price)
Plays best: Partial Prerendering. Cache the shell with use cache and stream the live price inside <Suspense>. The page is instant and the price is honest.
Beginner trap. Making the whole page dynamic just because the price is live. You throw away the instant static shell for the 95% of the page (title, description, photos, reviews) that never changes per request.
Here is the same advice as a quick right-versus-wrong lookup.
Scenario
Plays best
Tempting mistake
What breaks
Marketing / About
Static, long life
no-store "to be safe"
Re-renders identical HTML every request
Blog / catalog
Cache + tag, bust on publish
Short revalidate timer
Stale right after publish, plus constant refetch
User just wrote
updateTag in a Server Action
revalidateTag(tag, 'max')
User sees their own old data
Private per-user
Dynamic page, cache pieces per user
Cache whole page in shared cache
One user served another's data
Real-time
Do not cache
Cache for minutes
Decisions made on stale prices or scores
Static + one live bit
PPR: shell plus Suspense
Whole page dynamic
Lose the instant static shell
External images
next/image + remotePatterns
Raw <img> tag
Re-download, no optimization, layout shift
Interview answer: "I choose by two questions: how fresh must it be, and who is it for. Same-for-everyone and rarely-changing goes static; same-for-everyone with occasional updates gets cached and tag-busted on publish; data the user just wrote uses updateTag so they see it immediately; private per-user data keeps the page dynamic and caches only shared pieces tagged per user; real-time data is not cached. The two mistakes that matter most are caching private data in a shared cache, which leaks across users, and using revalidateTag where the user needs read-your-writes, which shows them stale data."
The mistakes and tricky edge cases interviewers probe
Gotcha. Assuming "fetch is cached by default." True on Next.js 14, false on 15 and 16. State the version before reasoning.
Gotcha. Expecting revalidateTag or revalidatePath to update the browser instantly. They clear the server caches; the client Router Cache catches up only on the next navigation, a Server Action response, or router.refresh().
Gotcha. Believing cache: 'no-store' makes a page always-fresh for the user. It only bypasses the server Data Cache; the browser Router Cache can still serve a previous visit for its staleness window.
Gotcha. A whole route turning dynamic unexpectedly because a shared layout added cookies() or headers(). Dynamic propagates downward from layouts.
Gotcha. Caching per-user data in a shared cache. Reading cookies or user data inside a default use cache scope is blocked for exactly this reason; pass stable inputs as arguments, or use use cache: private.
Gotcha. Using Date.now() or new Date() inside a prerendered cached component and hitting a prerender error. Compute the time outside the cached scope.
Gotcha. Relying on the deprecated single-argument revalidateTag('tag'). Pass a profile ('max') or move to updateTag.
Beginner trap. Self-hosting and expecting the server caches to be shared across instances. The Data Cache and Full Route Cache are filesystem-backed by default, so a multi-instance deployment needs sticky routing or a shared cache handler (for example Redis) for consistent behaviour.
Quick-reference cheat sheet
Mechanism
Lives
Stores
Bust it with
Request Memoization
Server (per render)
Repeated fetch results
Ends with the render (automatic)
React cache()
Server (per render)
Repeated non-fetch results
Ends with the render (automatic)
Data Cache
Server (persistent)
Fetch / unstable_cache / use cache results
revalidateTag, updateTag, revalidatePath, time
Full Route Cache
Server (persistent)
Rendered HTML + RSC for static routes
Data revalidation, redeploy, dynamic APIs
Router Cache
Browser (session)
RSC of visited routes
router.refresh(), navigation, Server Action
Image Optimization cache
Server (persistent)
Optimized images
minimumCacheTTL, URL versioning
Need
Reach for
Cache a fetch (Next 15)
fetch(url, { next: { revalidate, tags } }) or force-cache
Cache a DB query (Next 15)
unstable_cache(fn, keys, { tags, revalidate })
Cache anything (Next 16)
'use cache' + cacheLife + cacheTag
Time-based refresh
next.revalidate or cacheLife
Background on-demand bust
revalidateTag(tag, 'max')
Read-your-own-writes
updateTag(tag) in a Server Action
Push fresh data to the browser
router.refresh()
Mix static and live on one page
Cache Components + <Suspense> (PPR)
A worked end-to-end example: one blog, every concept
We have met every piece; now watch them all work together in a single blog called Inkwell. It has four routes, and each one makes a different caching choice for a reason. This is the capstone: if you can narrate this blog out loud, you understand the topic.
text
ROUTE CACHING CHOICE WHY
/about static, long life same for everyone, rarely changes
/ cached post list, tagged 'posts' same for everyone, updates on publish
/blog/[slug] static shell + streamed view count body is stable, view count is live
/dashboard dynamic (reads session) + cached bits private to the author
The data layer: dedupe first, then cache
A single post is read by several components in one render (the article, the page's meta tags, the "related posts" rail). Wrap the database read in React's cache() so it runs once per render (Request Memoization for non-fetch work), and wrap the cacheable version in use cache so the result persists across requests (the Data Cache).
jsx
import { cache } from 'react'
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/lib/db'
// Per-render dedupe: many components, one DB query.
export const getPost = cache(async (slug) => db.post.findUnique({ where: { slug } }))
// Cross-request cache: persists in the Data Cache, tagged for precise busting.
export async function getCachedPost(slug) {
'use cache'
cacheLife('days') // post bodies change rarely
cacheTag(`post-${slug}`) // bust just this post when it changes
return getPost(slug)
}
The post page: static shell, live hole (Partial Prerendering)
The post body is identical for everyone, so it belongs in the static shell stored in the Full Route Cache (as HTML plus the RSC payload from the explainer above). The view count is live, so it sits in a <Suspense> boundary and streams in as a dynamic hole. One route, partly cached and partly live.
jsx
import { Suspense } from 'react'
export default async function PostPage({ params }) {
const post = await getCachedPost(params.slug) // cached -> part of the static shell
return (
<article>
<Cover src={post.coverUrl} /> {/* external image, optimized and cached */}
<h1>{post.title}</h1>
<Markdown>{post.body}</Markdown>
<Suspense fallback={<span>...</span>}>
<ViewCount slug={params.slug} /> {/* dynamic hole, streamed fresh per request */}
</Suspense>
</article>
)
}
The cover image: external URL, optimized once
The cover lives on the CMS, a different domain. next/image pulls it, optimizes it once, and serves the optimized copy from the Image Optimization cache. The host must be allow-listed, which is also the security boundary that stops your app optimizing arbitrary URLs.
jsx
import Image from 'next/image'
export function Cover({ src }) {
return <Image src={src} alt="" width={1200} height={630} priority />
}
// next.config.ts: images.remotePatterns must include the CMS host; set minimumCacheTTL too.
Two ways a post changes, two invalidation tools
When the author hits Publish from the dashboard, they must see their post live immediately, so the Server Action uses updateTag (expire now and block for fresh data: read-your-own-writes). When an editor fixes a typo through the CMS, a webhook busts the tag in the background with revalidateTag(tag, 'max') (serve stale, refresh behind the scenes), because readers tolerate a few seconds of lag.
jsx
// app/dashboard/actions.js -- the author publishes and must see it now.
'use server'
import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function publishPost(slug, data) {
await db.post.update({ where: { slug }, data: { ...data, published: true } })
updateTag(`post-${slug}`) // read-your-writes: no stale flash for the author
updateTag('posts') // the home list must now include this post
redirect(`/blog/${slug}`)
}
A. First reader opens /blog/hello
Router Cache (browser): empty -> hit the server
Full Route Cache: miss (first ever) -> render the shell
during render: getCachedPost -> Data Cache miss -> DB -> stored in the Data Cache
ViewCount streams in via Suspense (the dynamic hole)
Server sends HTML + RSC payload; client JS hydrates; shell stored in Full Route Cache
B. Second reader opens /blog/hello
Full Route Cache: HIT -> shell served with no render and no DB call
ViewCount still streams live
C. Same reader clicks through to / then taps Back to /blog/hello
Router Cache (browser): HIT -> instant, no server request at all
D. Author edits and publishes from /dashboard
Server Action: db.update -> updateTag('post-hello') + updateTag('posts')
Data Cache and Full Route Cache for that post expire and block for fresh
Author is redirected and sees the new version immediately (read-your-writes)
E. Editor fixes a typo via the CMS webhook
revalidateTag('post-hello','max') -> Data Cache marked stale
the next visitor triggers a background refresh; the very first may see slightly old text
F. A reader who already loaded the old post still holds it in their Router Cache
they see fresh content only on their next navigation, a Server Action, or router.refresh()
Gotcha (the one that ties it all together). Step F is the classic trap: the author published (D) and an editor revalidated (E), yet a reader who already loaded the page can still see the old version until their browser's Router Cache turns over on a navigation. Server revalidation reaches the server caches; the browser needs a navigation event to catch up.
Master Node.js for interviews: the event loop, async patterns, streams, concurrency, and the beginner traps that quietly sink candidates. With worked examples.