Data & Caching — Cheat Sheet
Next.js · 8 topics. Download the PDF or the Instagram carousel and share it.
Fetching Data in Server Components
An async component that awaits its own data. No useEffect, no loading flag, no API route in the middle — and the waterfall problem moves from the browser to your control.
- ✓A server component can be async and await its data, removing the loading flag, error state and race handling entirely
- ✓Errors thrown during fetching are caught by error.tsx, so there is no error state to manage
- ✓Independent awaits must be wrapped in Promise.all, or each one waits for the last
- ✓Nested async components serialise; give each a Suspense boundary so they stream in parallel
- ✓React memoizes identical fetch calls within one render, and cache() extends that to database and SDK calls
// app/problems/page.tsx — a server component
export default async function ProblemsPage() {
const problems = await getProblems() // no useEffect, no state
return <ProblemList problems={problems} />
}
// It can talk to the database directly, because it runs on the server
import { db } from '@/lib/db'
const problems = await db.problem.findMany({ where: { published: true } })
// Or to your own API — for this platform, FastAPI on Fly:
const res = await fetch(`${process.env.API_URL}/problems`, {
headers: { Authorization: `Bearer ${process.env.SERVICE_TOKEN}` },
})
if (!res.ok) throw new Error(`API ${res.status}`) // -> error.tsx
const problems = await res.json()
// A thrown error goes to the nearest error.tsx. A notFound() goes to
// not-found.tsx. There is no error STATE to manage — the boundaries
// are the error handling.
// Note what is NOT needed here: an /api route in your own app just to
// reach your own database. That indirection exists in the Pages
// Router because the browser was doing the fetching; here it is pure
// overhead.The Caching Layers
Four caches sit between a request and your data, each with its own lifetime and its own way of being wrong. Naming them is what makes a staleness bug diagnosable.
- ✓Four caches: request memoization, the data cache, the full route cache and the client router cache
- ✓A hard refresh distinguishes them — fresh after a hard refresh means the client router cache is at fault
- ✓Reading cookies, headers or searchParams anywhere in a tree makes the whole route dynamic
- ✓The build output labels each route static or dynamic, which is how you catch an accidental regression
- ✓Next 14 caches fetch by default and Next 15 does not — always set cache or revalidate explicitly
// 1. REQUEST MEMOIZATION — React, per render pass, server
// Dedupes identical fetch/cache() calls in one render.
// Lifetime: one render. Not configurable, and never a bug.
// 2. DATA CACHE — Next, persistent, server, across requests AND deploys
// Stores the RESULT of a fetch.
// Next 14: cached by default. Next 15: NOT cached by default.
fetch(url, { cache: 'force-cache' }) // opt in (15)
fetch(url, { cache: 'no-store' }) // opt out (14)
fetch(url, { next: { revalidate: 60 } }) // time-based
fetch(url, { next: { tags: ['problems'] } }) // taggable
// 3. FULL ROUTE CACHE — Next, build/server, the rendered HTML and RSC
// payload of a STATIC route. Cleared by a deploy or revalidation.
// 4. ROUTER CACHE — the browser, in memory, per session
// Rendered segments from routes you visited.
// Next 14: 30s dynamic / 5min static. Next 15: 0 / 5min.
// Reading order on a request:
// router cache -> full route cache -> data cache -> your source
// Diagnostic that saves an hour: does a HARD REFRESH show fresh data?
// yes -> the router cache (client)
// no -> the data cache or the full route cache (server)Revalidation — Keeping Cached Data Fresh
Time-based revalidation is a guess about how stale you can afford to be. On-demand revalidation is knowing exactly when something changed — and it is almost always the better answer.
- ✓Time-based revalidation serves a stale copy to the first visitor after expiry and regenerates in the background
- ✓A route revalidates at the shortest interval of anything inside it
- ✓On-demand revalidation with tags invalidates exactly the pages a write affected and is the better default
- ✓An external revalidation endpoint must be authenticated, or anyone can force continuous regeneration
- ✓Never cache per-user data — a cache keyed by URL alone will eventually serve one user another user's page
// Whole route
export const revalidate = 3600 // seconds
// One fetch
fetch(url, { next: { revalidate: 60 } })
// The route uses the SHORTEST revalidate of anything inside it.
// The behaviour is stale-while-revalidate, which surprises people:
// request at t=0 -> generated, cached
// request at t=30 -> served from cache (fast)
// request at t=70 -> STALE COPY SERVED, regeneration starts
// request at t=71 -> fresh copy
// So the first visitor after expiry still sees the old page. Nobody
// waits for a rebuild, and nobody is guaranteed the newest content.
// Choose the interval from how wrong you can afford to be:
// a published article 3600 or more
// a problem list 300
// a leaderboard 30, or on-demand
// a price, a balance never cache
// revalidate = 0 means "always dynamic" and is the same as
// force-dynamic — not "revalidate immediately".Streaming and Suspense
Send the shell immediately and let slow parts arrive as they resolve. One slow query stops holding the entire page hostage.
- ✓Streaming sends the shell immediately and pushes slow content down the same response as it resolves
- ✓The await must be inside the Suspense boundary, or the page waits before rendering anything
- ✓Start fetches without awaiting and pass the promises down to avoid a waterfall between boundaries
- ✓use() lets a client component consume a promise started on the server
- ✓Once streaming starts the status and headers are sent, so auth checks and redirects must happen before the first boundary
// No boundary — the whole page waits for the slowest query
export default async function Page() {
const [problem, stats, comments] = await Promise.all([...]) // 900ms
return <>…</> // nothing until then
}
// Boundaries — the shell is instant, each part streams
export default async function Page({ params }) {
const problem = await getProblem(params.slug) // fast, 40ms
return (
<>
<ProblemHeader problem={problem} /> {/* immediate */}
<Suspense fallback={<StatsSkeleton />}>
<Stats slug={params.slug} /> {/* 300ms, streams */}
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments slug={params.slug} /> {/* 900ms, streams */}
</Suspense>
</>
)
}
// The awaited component must be INSIDE the boundary. This does not
// stream — the await happens before any JSX is returned:
const comments = await getComments()
return <Suspense fallback={...}><List items={comments} /></Suspense>
// loading.tsx is the same mechanism at segment level: it wraps the
// whole page in one boundary.Static or Dynamic — Reading the Build Output
Every route is one or the other, the build tells you which, and a route silently flipping to dynamic is the most common performance regression in a Next codebase.
- ✓The build output labels every route static, SSG, dynamic or edge — read it after each build
- ✓A route that unexpectedly became dynamic is the most common silent performance regression
- ✓Dynamic APIs are inherited downward, so one cookies() call in a root layout makes the whole app dynamic
- ✓Keep a page static by moving per-user UI into a client component or isolating it behind Suspense
- ✓generateStaticParams with dynamicParams: true prerenders the popular paths and renders the tail on demand
Route (app) Size First Load JS ┌ ○ / 5.2 kB 98 kB ├ ● /learn/[topic]/[concept] 1.8 kB 95 kB ├ ├ /learn/react/react-usestate ├ └ [+199 more paths] ├ ƒ /dashboard 3.1 kB 96 kB └ ƒ /api/health 0 B 0 B // ○ Static prerendered at build, served from the CDN. Fastest. // ● SSG prerendered from generateStaticParams. Also CDN. // ƒ Dynamic rendered per request on a server. Costs latency and money. // λ / Edge runs on the edge runtime. // What to look for after a build: // - a page you expected to be ○ showing as ƒ // - First Load JS creeping up release after release // - a route with far more paths than you expected // Diffing this between deploys catches regressions that no test does. // Some teams commit the summary and review changes to it. // Force a decision when you want it enforced rather than inferred: export const dynamic = 'force-static' // build fails on dynamic API use export const dynamic = 'force-dynamic' // never prerender
Partial Prerendering
One route that is static and dynamic at once: the shell comes from the CDN instantly, and the personalised holes stream in. It removes the choice this whole category has been about.
- ✓PPR serves a prerendered shell from the edge and streams the request-dependent parts into it
- ✓It moves the static-versus-dynamic decision from the route level to the component level
- ✓Any dynamic read must sit inside a Suspense boundary, and the fallback is part of the static shell
- ✓The shell should carry the real content — a shell made only of skeletons gains nothing
- ✓It is still experimental, but the small-boundary discipline it requires improves streaming today
// next.config.js
module.exports = { experimental: { ppr: 'incremental' } }
// app/problems/[slug]/page.tsx
export const experimental_ppr = true
export default async function Page({ params }) {
const problem = await getProblem(params.slug) // static: build time
return (
<>
<ProblemHeader problem={problem} /> {/* prerendered shell */}
<ProblemBody problem={problem} /> {/* prerendered shell */}
<Suspense fallback={<BookmarkSkeleton />}>
<BookmarkState slug={params.slug} /> {/* reads cookies() —
streamed per request */}
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments slug={params.slug} /> {/* live data */}
</Suspense>
</>
)
}
// What the user gets:
// ~0ms the shell, from the CDN — content is readable immediately
// ~200ms the personalised holes fill in
// Before PPR this page had to be entirely dynamic because of one
// cookie read.Client-Side Data Alongside Server Components
Server components do not remove the need for client fetching. Knowing which of the two a piece of data belongs to — and how to hand data from one to the other — is the real skill.
- ✓Server components own the first paint and SEO; client fetching owns interaction, polling and optimistic updates
- ✓Pass server-fetched data as initialData so the client cache does not refetch it on mount
- ✓HydrationBoundary transfers a whole prefetched cache from server to client
- ✓For a read-heavy app, server components plus server actions often remove the need for a data library entirely
- ✓Never fetch your own route handler from a server component — that is an HTTP round trip to yourself
// SERVER COMPONENT when the data is: // needed for the first paint or for SEO // the same for everyone, or cacheable per user // large, or expensive to compute // from a source with a secret (a database, a keyed API) // CLIENT when the data: // changes in response to interaction (search-as-you-type, filters) // polls or streams (a live job status, a leaderboard) // is optimistically updated (a like, a toggle) // depends on a browser API (geolocation, media queries) // The tell that you have it wrong: a server round trip on every // keystroke, or a spinner on a page that could have been prerendered. // A search page usually wants BOTH: the initial results rendered on // the server for SEO and first paint, then client-side fetching as // the user types.
Calling a Separate Backend
When the real API is FastAPI or Spring Boot, Next becomes a client of it — and the choice of whether the browser or the Next server makes that call changes auth, CORS and latency.
- ✓Calling the backend from the Next server removes CORS entirely and keeps the token off the browser, at the cost of a hop
- ✓A server component sends no cookies automatically — forward them explicitly or the request is anonymous
- ✓Per-user responses must use no-store, or one user's data can be served inside another user's page
- ✓Generate client types from the backend's OpenAPI schema and fail CI on drift
- ✓Bound every outbound call with a timeout and degrade gracefully, since two platforms mean two cold starts
// A. The browser calls the backend directly
// browser -> api.aicancode.org (FastAPI on Fly)
// + one hop, no Next server involved
// - CORS applies, the token must be reachable by JS or the cookie
// must be same-site, and nothing is server-rendered
NEXT_PUBLIC_API_URL=https://api.aicancode.org
// B. The Next server calls the backend, then renders
// browser -> Next (Vercel) -> api.aicancode.org
// + no CORS at all (server to server), the token never reaches the
// browser, HTML arrives ready to read, SEO works
// - an extra hop, and Vercel and Fly should be in the same region
// or you pay for the distance twice
API_URL=https://api.aicancode.org // server-only, no NEXT_PUBLIC_
// Most apps use both: B for the initial render and anything secret,
// A for interactive updates after the page is live.
// A third option worth knowing — proxy through Next so the browser
// only ever sees one origin:
// next.config.js
async rewrites() {
return [{ source: '/api/:path*', destination: `${process.env.API_URL}/:path*` }]
}
// Cookies become first-party and CORS disappears, at the cost of
// routing traffic through Vercel.