Caching 101 🍱
Caching is just saving a previous answer so that the next visitor does not have to wait for the work to happen again. In the web, that means keeping a copy of rendered HTML, fetched data, or computed assets somewhere closer to the user. This guide distills the excellent explanations from Cole Ruche’s overview into a few plain steps.
Why it matters 🌟
- ⚡ Faster perceived performance because fewer requests reach your server or data source.
- 💰 Lower infrastructure cost because expensive computations are reused.
- 🛡️ More resilient apps: if an upstream API is slow, the cached copy can still be served.
Think of caching as keeping a ready-to-serve lunchbox. If the ingredients (data) change, you repack the lunchbox, otherwise you simply hand it out.
Key caching layers in Next.js 🗂️
1. Request memoization 🔁
fetch() calls with the same URL and options during a single request life-cycle are deduplicated. If two server components ask for the same resource, Next.js resolves it once and shares the promise with both. This lives only for the duration of the request; once the response is sent to the browser, the memoized result disappears.
🧠 Analogy: request memoization is like asking a friend a question once and then forwarding their recorded answer to everyone else before they walk away.
// app/products/page.tsx
import ProductsList from '@/components/products-list'
export default async function ProductsPage() {
// These two calls resolve once because the URL/options match
const featured = await fetch('https://api.example.com/products', {
cache: 'no-store', // memoization still applies inside one request
}).then((res) => res.json())
const reused = await fetch('https://api.example.com/products', {
cache: 'no-store',
}).then((res) => res.json())
return <ProductsList initialData={featured} compare={featured === reused} />
}Memoization is automatic, so you do not need to install Redis or tweak headers to benefit. Just avoid mutating the returned object directly; treat it as read-only data.
2. Data Cache 🗃️
When you provide next: { revalidate: n } or cache: 'force-cache', Next.js stores successful fetch() responses on the server and reuses them across requests. The entry stays fresh until it expires or you manually revalidate with cache tags. Think of it as a built-in key-value store keyed by the URL plus options you pass to fetch().
📦 Analogy: the data cache is a labeled pantry—you cook once, store the leftovers with a date, and only cook again when the label says it is time.
// app/api/products/route.ts
import { NextResponse } from 'next/server'
import { revalidateTag } from 'next/cache'
export async function GET() {
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 300, tags: ['products'] },
}).then((res) => res.json())
return NextResponse.json(products)
}
export async function POST(request: Request) {
await saveProduct(await request.json())
revalidateTag('products') // bust only the affected fetches
return NextResponse.json({ ok: true })
}Use low revalidate values for data that changes often (stock levels) and higher ones for marketing copy (hours or days).
3. Full Route Cache 🏛️
Static routes (export const dynamic = 'force-static', or no dynamic data) are rendered once at build time or on first request (Incremental Static Regeneration) and the HTML is stored. Subsequent visitors get the cached HTML instantly until revalidation kicks in. You can opt into ISR with a simple export in the page module.
🏠 Analogy: the full route cache is a show home—decorated once and toured by thousands until it gets a scheduled refresh.
// app/blog/[slug]/page.tsx
export const revalidate = 60 // rebuild every minute when traffic hits the page
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await fetch(`https://cms.io/posts/${params.slug}`, {
next: { tags: ['blog-post'] },
}).then((res) => res.json())
return <article dangerouslySetInnerHTML={{ __html: post.html }} />
}If a post is edited, call revalidateTag('blog-post') in your CMS webhook so the cached HTML and data are regenerated.
4. Router Cache (Client-side) 🧭
On the client, Next.js keeps recently visited route segments in memory. Navigations feel instant because the framework reuses component trees and preloaded data. Use prefetch (enabled by default on visible Link components) to warm that cache before the user clicks.
🧳 Analogy: the router cache is the carry-on bag you keep with essentials so you do not have to unpack the big suitcase (server) again during a short trip.
'use client'
import Link from 'next/link'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
export function NavLinks() {
const router = useRouter()
useEffect(() => {
router.prefetch('/docs/caching') // keep this page warm for future nav
}, [router])
return (
<nav>
<Link href="/docs">Docs</Link>
<Link href="/docs/caching">Caching guide</Link>
</nav>
)
}Remember that this cache lives only in the user’s browser tab. A hard refresh or new tab starts fresh.
5. CDN & Browser Cache 🌍
Anything output to /public or handled by Vercel Edge/CDN enjoys traditional HTTP caching. Proper Cache-Control headers decide how long browsers and edge nodes should keep those bytes. Large assets (images, fonts, audio) should ship with a long max-age plus a versioned filename to avoid stale resources.
🚚 Analogy: CDN caches are delivery lockers scattered across the city—drop packages there once and everyone grabs them from the closest locker.
// app/api/avatar/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
const image = await fetch('https://images.example.com/avatar.png')
return new NextResponse(image.body, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=300',
},
})
}If you deploy on Vercel, these headers are honored at the edge automatically. On self-managed setups, configure your CDN (CloudFront, Fastly, etc.) to respect them.
Picking the right strategy 🎯
| Situation | Simple rule of thumb |
|---|---|
| Data rarely changes | Use cache: 'force-cache' or ISR so everyone reuses the same result. |
| Data changes on a schedule | Add revalidate: <seconds> to fetch() or the route so Next.js refreshes periodically. |
| Data must be fresh per user | Set cache: 'no-store' or dynamic = 'force-dynamic' to skip caches entirely. |
| Selective busting required | Tag your fetches with next: { tags: ['products'] } and call revalidateTag('products') after updates. |
Practical tips 🛠️
- 🧊 Start static, add dynamism later. It is easier to relax caching bit by bit than to optimize a dynamic page retroactively.
- 🏷️ Name your cache tags per domain concept (e.g.,
blog-post,inventory). A mutation can then invalidate only the affected slices. - 🪪 Log cache headers in development (
next dev --turboprints them) to confirm what is actually being cached. - 🔁 Remember the client cache. When debugging, hard refresh (
Ctrl + Shift + R) or open a new tab to avoid router cache noise.