Caching is the part of Next.js that changed the most between versions, and the part most likely to produce a production incident: a price page that serves yesterday's number, a dashboard that shows another tenant's data because a cached function closed over the wrong variable, or a Server Action that anyone on the internet can call because the page that renders it was protected and the action was not. This post covers how to fetch data in Server Components without waterfalls, the four caching layers of Next.js 14, what 15 changed, how Next.js 16 replaced implicit caching with the explicit use cache model (Cache Components), the revalidation APIs and their exact semantics, Server Actions (forms, progressive enhancement, the single-roundtrip response, the security model), and Route Handlers. Every behaviour is tagged with the version it applies to, because a blog post or Stack Overflow answer from 2024 is often wrong for a 2026 codebase.

Series map: 00 App Router Mental Model → 01 Data Fetching, Caching & Server Actions (this post) → 02 Rendering Strategies & Runtimes → 03 Production Next.js (future: see the extensions table in 03).


Learning Outcome

  • Fetch data in Server Components in parallel, avoid request waterfalls, and deduplicate with React.cache and fetch memoisation.
  • Build a server-only Data Access Layer that returns minimal DTOs.
  • Name the four caching layers of Next.js 14 and what each one stores, for how long, and where.
  • State exactly what changed in Next.js 15 (async request APIs, fetch default, GET handlers, router cache) and in 16 (Cache Components).
  • Use 'use cache', cacheLife, cacheTag correctly, including cache-key composition, closures, and the runtime-API restriction.
  • Choose between updateTag, revalidateTag, revalidatePath and refresh after a mutation.
  • Write Server Actions with progressive enhancement, useActionState and useOptimistic, and secure them as public endpoints.
  • Decide when a Route Handler is the right tool instead of a Server Action.

Chapter 1 — Fetching in Server Components

A Server Component can be async and await data directly: no getServerSideProps, no client-side loading state for the first render, and no API layer between the component and the data source unless you want one.

Waterfalls and how to avoid them

Sequential awaits in one component run one after another. Nested async components are worse: a child cannot start fetching until its parent has finished rendering.

1
2
3
4
5
6
7
8
// Sequential: total time = user + orders + recommendations
const user = await getUser(id)
const orders = await getOrders(id)
const recs = await getRecommendations(id)

// Parallel: total time = the slowest of the three
const [user, orders, recs] = await Promise.all([getUser(id), getOrders(id), getRecommendations(id)])

When the calls are independent but the UI can show them at different times, do not await them together at the top. Start each in its own Suspense-wrapped component so each part streams as soon as it is ready (Post 02 covers streaming):

1
2
3
4
5
6
7
8
9
export default function Page({ params }: { params: Promise<{ id: string }> }) {
return (
<>
<Suspense fallback={<ProfileSkeleton />}><Profile params={params} /></Suspense>
<Suspense fallback={<OrdersSkeleton />}><Orders params={params} /></Suspense>
</>

)
}

A dependent chain (you need the user's teamId before fetching the team) is a real dependency, not a waterfall to fix.

Deduplication: fetch memoisation and React.cache

Two different components often need the same data (the layout shows the user's name, the page shows the user's plan). You do not need to fetch once and drill props down.

  • Fetch memoisation. fetch requests using GET with the same URL and options are executed once per server render pass, across layouts, pages and other Server Components. Memoisation lasts only for that render pass. It does not apply in Route Handlers (they are not part of the React tree). Pass an AbortController signal to opt out.
  • React.cache does the same for any function (a database query, an SDK call):
1
2
3
4
5
6
7
8
9
// data/user.ts
import 'server-only'
import { cache } from 'react'
import { db } from '@/lib/db'

export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id }, select: { id: true, name: true, plan: true } })
})

Calls with the same arguments during the same request share one promise. The cache is thrown away when the request ends, so it never serves one user's data to another.

Watch out: React.cache compares arguments with Object.is. getUser({ id }) with a fresh object literal on each call is a cache miss every time. Use primitive arguments.

The Data Access Layer

For new projects, Next.js recommends a dedicated server-only Data Access Layer (DAL): the only code that reads process.env secrets and talks to the database, performs authorisation, and returns minimal Data Transfer Objects instead of raw rows.

1
2
3
4
5
6
7
8
9
10
11
12
// data/auth.ts
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { decryptSession } from '@/lib/session'

export const getCurrentUser = cache(async () => {
const token = (await cookies()).get('session')?.value
const session = token ? await decryptSession(token) : null
return session ? { id: session.userId, role: session.role } : null
})
1
2
3
4
5
6
7
8
9
10
11
12
13
// data/invoices.ts
import 'server-only'
import { getCurrentUser } from './auth'
import { db } from '@/lib/db'

export async function getInvoiceDTO(invoiceId: string) {
const user = await getCurrentUser()
if (!user) return null
const inv = await db.invoice.findUnique({ where: { id: invoiceId } })
if (!inv || (inv.ownerId !== user.id && user.role !== 'admin')) return null
return { id: inv.id, total: inv.total, status: inv.status } // no ownerId, no internal notes
}

Pages and actions call the DAL and never import the ORM directly, so an auditor has one folder to review.


Chapter 2 — The four caches of Next.js 14

Next.js 14 (and 13.4+) had four layers, most of them on by default. A great deal of 2023-2024 content assumes this model.

Layer What it stores Where Lifetime Default in 14
Request memoisation Return values of fetch (GET) in one render Server memory One request On
Data Cache fetch responses Server (persistent; survives deploys) Until revalidated On: fetch cached unless opted out
Full Route Cache Rendered HTML + RSC payload of static routes Server (build output / cache) Until revalidated or redeployed On for static routes
Router Cache RSC payload of visited and prefetched segments Browser memory 30 s dynamic, 5 min static On

In 14, a fetch was cached in the Data Cache unless something opted it out: cache: 'no-store', next: { revalidate: 0 }, a POST Route Handler, reading cookies() or headers() before it, export const dynamic = 'force-dynamic', and a few more heuristics. Non-fetch data (an ORM query) was cached through unstable_cache.

The model was fast by default and confusing by default. The typical incident: a developer adds a fetch to an external pricing API, it works in development, and production serves the build-time price for days because the Data Cache persisted the response and the route was prerendered as static.


Chapter 3 — What Next.js 15 changed

Next.js 15 (October 2024) flipped several defaults to "uncached unless you ask":

Area Next.js 14 Next.js 15
fetch default force-cache auto no cache
GET Route Handlers Cached (static) by default Not cached by default; opt in with export const dynamic = 'force-static'
Router Cache, page segments 30 s (dynamic) staleTime 0; layouts, back/forward and loading.js (5 min) still cached
cookies(), headers(), draftMode(), params, searchParams Synchronous Async (sync access warned; removed in 16)
export const dynamic = 'force-dynamic' Route rendered per request Also sets no-store default for fetch
revalidateTag / revalidatePath during render Allowed Throws

"auto no cache" is subtle. The fetch is not stored in the Data Cache, but if the route is prerendered statically at build time, the fetch runs once during next build and its result is baked into the static HTML. If the route uses a request-time API (cookies(), headers(), searchParams, connection()), the fetch runs on every request. So "15 does not cache fetch" is only half true: a static route still freezes the data until the route is revalidated.

Tags are limited to 256 characters each and 128 per entry.

1
2
3
4
5
6
fetch(url)                                          // auto no cache (15+ default)
fetch(url, { cache: 'force-cache' }) // store in the Data Cache
fetch(url, { cache: 'no-store' }) // always fetch, even on static routes
fetch(url, { next: { revalidate: 3600 } }) // cache, at most 1 hour old
fetch(url, { next: { tags: ['products'] } }) // cache with a tag for on-demand revalidation

Common pitfall: in development, fetch responses are reused across Hot Module Replacement (serverComponentsHmrCache), even with no-store. "The data does not refresh in dev" is often this, not a caching bug in production. A full reload clears it.


Chapter 4 — Next.js 16: Cache Components and use cache

The new default: dynamic, with explicit caching

Next.js 16 (October 2025) introduced Cache Components, enabled with one flag:

1
2
3
4
5
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { cacheComponents: true }
export default nextConfig

With the flag on, all data access is dynamic by default and nothing is cached unless you mark it with the 'use cache' directive. The flag replaces the 15-era experimental flags dynamicIO, useCache and ppr. The route segment configs dynamic, revalidate and fetchCache are no longer allowed on those routes (the build errors), and dynamicParams fails the build. Cache Components requires the Node.js runtime.

The diagram below lines up the three generations of defaults.

How Next.js caching defaults changed from version 14 to 15 to 16: fetch default, GET route handlers, client router cache for pages, the caching primitive and the invalidation APIs

'use cache' at three levels

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { cacheLife, cacheTag } from 'next/cache'

// 1. A data function
export async function getProduct(id: string) {
'use cache'
cacheLife('hours')
cacheTag(`product-${id}`)
return db.product.findUnique({ where: { id } })
}

// 2. A component: its rendered output is cached, keyed by its props
export async function ProductCard({ id }: { id: string }) {
'use cache'
cacheLife('hours')
const p = await getProduct(id)
return <article>{p?.name}</article>
}

// 3. A whole file: every export is cached (and must be async)
// 'use cache' <- first line of the module

Functions and components marked 'use cache' must be async.

What goes into the cache key

A cache entry's key is built from:

  1. Build ID (or deploymentId if configured). A new deploy invalidates every entry.
  2. Function ID: a secure hash of the function's location and signature.
  3. Serialisable arguments: props for components, arguments for functions.
  4. Closed-over variables: variables captured from an outer scope are bound as arguments automatically.
  5. In development only, an HMR refresh hash.

Point 4 is what makes nested cached functions safe:

1
2
3
4
5
6
7
8
9
async function Invoices({ tenantId }: { tenantId: string }) {
const list = async (status: string) => {
'use cache'
// key includes tenantId (closure) AND status (argument)
return db.invoice.findMany({ where: { tenantId, status } })
}
return <Table rows={await list('open')} />
}

Arguments and return values must be serialisable (Post 00's rules). You may accept a non-serialisable value (such as children or a Server Action) only as pass-through: return it in JSX without reading it. That is how a cached layout can wrap uncached children: slots do not affect the cache entry.

The runtime-API rule

Inside a 'use cache' scope you cannot call cookies(), headers() or read searchParams, and the rule follows the call stack: a helper that reads cookies fails with the next-request-in-use-cache error. Read the value outside and pass it in:

1
2
3
4
5
6
7
8
9
10
async function Page() {
const locale = (await cookies()).get('locale')?.value ?? 'en'
return <Catalog locale={locale} /> // locale becomes part of the key
}
async function Catalog({ locale }: { locale: string }) {
'use cache'
cacheLife('days')
return <List items={await getCatalog(locale)} />
}

Watch out: passing the promise from cookies() into a cached function does not error immediately. The build waits for data that can never resolve at build time, and times out after 50 seconds with "Filling a cache during prerender timed out". Await runtime data first; pass plain values.

React.cache scopes are isolated inside 'use cache': a value stored via React.cache outside is not visible inside. Pass data by argument.

cacheLife profiles

cacheLife sets three times: stale (how long the browser may reuse the data without asking), revalidate (after this, the next request serves the cached value and regenerates in the background), and expire (after this with no traffic, the next request waits for fresh data).

Profile stale revalidate expire
default (no cacheLife call) 5 min 15 min never
seconds 30 s 1 s 1 min
minutes 5 min 1 min 1 h
hours 5 min 1 h 1 day
days 5 min 1 day 1 week
weeks 5 min 1 week 30 days
max 5 min 30 days 1 year

Custom profiles go in next.config.ts under cacheLife: { editorial: { stale: 600, revalidate: 3600, expire: 86400 } }; expire must be longer than revalidate. The client router enforces a minimum stale time of 30 seconds regardless of configuration, communicated through the x-nextjs-stale-time response header.

Two rules that bite:

  • A short lifetime (revalidate 0, or expire under 5 minutes, as with seconds) cannot be prerendered; the entry becomes a dynamic hole resolved at request time and needs a Suspense boundary.
  • Nesting a short-lived cache inside a 'use cache' scope that has no explicit cacheLife fails the build, because the outer entry would silently become short-lived too. Always call cacheLife explicitly.

Where entries live: memory, remote, private

  • 'use cache' stores entries through a cache handler, in memory (LRU) by default. Self-hosted, entries persist across requests in that process (size via cacheMaxMemorySize). On serverless, instances are ephemeral, so runtime entries often do not survive between requests; build-time entries work normally.
  • 'use cache: remote' lets the platform (or your cacheHandlers config) store entries in a shared store such as Redis. It costs a network round trip per lookup.
  • 'use cache: private' exists for the rare case where you cannot refactor runtime data out of the cached scope. Results are not stored in a server cache across production requests.

Watch out: neither 'use cache' nor 'use cache: remote' survives a deploy, because the build ID is in the key. The legacy fetch Data Cache and unstable_cache do persist across deploys. Migrating a slow upstream call from unstable_cache to 'use cache' changes post-deploy load on that upstream.


Chapter 5 — Revalidation after a mutation

The four APIs

API What it does Where it can be called Re-render in the action's response?
updateTag(tag) (16) Expires the tag immediately; the next read waits for fresh data (read-your-own-writes) Server Actions only Yes
revalidateTag(tag, profile) Marks the tag stale; reads get the old value while a background refresh runs (stale-while-revalidate) Server Actions, Route Handlers No
revalidatePath(path, type?) Invalidates by URL (type is 'page' or 'layout' for dynamic patterns) Server Actions, Route Handlers Yes
refresh() (16) Re-fetches the current route's uncached data; touches no cache Server Actions only Yes

In Next.js 16, revalidateTag takes a cacheLife profile as its second argument ('max' is the recommended default). The one-argument form is deprecated. In 14 and 15 it took only the tag.

1
2
3
4
5
6
7
8
9
'use server'
import { updateTag, revalidateTag } from 'next/cache'

export async function renameProject(id: string, name: string) {
await dal.renameProject(id, name) // auth + authz inside the DAL
updateTag(`project-${id}`) // the user sees the new name in this response
revalidateTag('project-list', 'max') // other lists refresh in the background
}
1
2
3
4
5
6
7
8
9
10
11
// app/api/cms-webhook/route.ts: a webhook cannot use updateTag
import { revalidateTag } from 'next/cache'
export async function POST(req: Request) {
if (req.headers.get('x-cms-secret') !== process.env.CMS_WEBHOOK_SECRET) {
return new Response('forbidden', { status: 403 })
}
const { tag } = await req.json()
revalidateTag(String(tag), 'max')
return Response.json({ ok: true })
}

When any of these runs inside a Server Action, the whole client router cache is cleared, bypassing stale times. revalidatePath is implemented on top of tags: it calls revalidateTag with a special default tag for that page.

Common pitfall: on a multi-instance self-hosted deployment, revalidateTag on one instance invalidates only that instance's cache by default. Coordinating tags needs a shared cache handler that implements refreshTags() (Post 03).


Chapter 6 — Server Actions and Server Functions

Vocabulary

React calls any function marked 'use server' a Server Function. When it is used through React's action mechanisms (<form action>, <button formAction>, or called inside startTransition), it is a Server Action. Next.js docs use both terms; they describe the same compiled artefact.

At build time, 'use server' tells the compiler to replace the function in client bundles with a reference: an action ID plus a dispatcher that POSTs to the route of the page that invokes it. The implementation stays on the server. Since 15, IDs are unguessable and non-deterministic, rotated at most every 14 days, and actions that nothing imports are removed from the client bundle entirely (dead code elimination).

Forms and progressive enhancement

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// app/projects/actions.ts
'use server'
import { z } from 'zod'
import { createProject } from '@/data/projects'
import { redirect } from 'next/navigation'

const Schema = z.object({ name: z.string().min(1).max(80) })
export type State = { error?: string }

export async function create(prev: State, formData: FormData): Promise<State> {
const parsed = Schema.safeParse({ name: formData.get('name') })
if (!parsed.success) return { error: 'Name is required (max 80 characters).' }
const project = await createProject(parsed.data.name) // authenticates inside
redirect(`/projects/${project.id}`) // throws: code after it does not run
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// app/projects/new/form.tsx
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { create, type State } from '../actions'

function Submit() {
const { pending } = useFormStatus()
return <button disabled={pending}>{pending ? 'Creating…' : 'Create'}</button>
}

export function NewProjectForm() {
const [state, action] = useActionState<State, FormData>(create, {})
return (
<form action={action}>
<input name="name" required maxLength={80} />
{state.error && <p role="alert">{state.error}</p>}
<Submit />
</form>

)
}

The form is a real HTML <form> posting to the page URL, so it can submit before JavaScript has loaded (progressive enhancement). useActionState replaced the earlier useFormState from React canaries (the name in Next.js 14 era code).

For instant feedback, useOptimistic shows the expected result while the action runs and rolls back automatically if it fails.

One round trip carries data and UI

When an action calls updateTag, revalidatePath or refresh, mutates cookies, or calls redirect, Next.js re-renders the current route (or the redirect target) in the same response. One Flight stream carries both the action's return value and the new RSC payload. No follow-up fetch is needed. revalidateTag with a profile is the exception: it only schedules a background refresh.

Put revalidation calls before redirect, because redirect throws.

Actions are dispatched one at a time per client. Promise.all([actionA(), actionB()]) from the browser does not run them in parallel; the second waits for the first. Parallel work belongs inside one action, in a Server Component, or in a Route Handler.

Security model: every action is a public endpoint

The single most important fact: an exported Server Action is reachable by anyone who can send the same POST, whether or not your UI renders the form. A page-level auth check protects the page's HTML, not the action.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'

// UNSAFE: no authentication, no ownership check, trusts the whole object
export async function completeItemUnsafe(item: { id: string }) {
await db.item.update({ where: { id: item.id }, data: { completed: true } })
}

// SAFE: identity from the session, ownership in the query, minimal return
export async function completeItem(itemId: string) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const item = await db.item.findFirst({ where: { id: itemId, ownerId: session.user.id } })
if (!item) throw new Error('Forbidden')
await db.item.update({ where: { id: item.id }, data: { completed: true } })
return { ok: true }
}

Checklist for every action:

  1. Authenticate (is there a user?) and authorise (may this user touch this resource?). Missing authorisation is an IDOR bug.
  2. Validate input with a schema. Schema validation checks shape, not ownership.
  3. Return the minimum. Return values are serialised to the client; never return a raw database row.
  4. Keep it thin. Delegate to the server-only DAL so the checks live in one place.

What the framework adds on top:

Protection Detail
CSRF check Only POST invokes actions. The Origin header's host is compared with Host / X-Forwarded-Host; a mismatch is rejected. Extra hosts go in experimental.serverActions.allowedOrigins (* = one label, ** = one or more). A request with no Origin header is allowed through with a warning.
Body size limit 1 MB by default; experimental.serverActions.bodySizeLimit (e.g. '2mb').
Secure IDs + dead code elimination Since 15; IDs rotate at most every 14 days.
Closure encryption Variables captured by an inline action are encrypted before being sent to the client, with a new key per build. Set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY (base64 AES key of 16, 24 or 32 bytes) on multi-instance deployments, or instances cannot decrypt each other's references.

Watch out: closure encryption hides captured values from casual inspection, but Next.js explicitly says not to rely on it alone. Do not close over secrets; re-read sensitive values on the server inside the action.

Common pitfall: a Proxy (proxy.ts, formerly middleware.ts) matcher that excludes a path also skips every Server Action posted to that path, because actions are POSTs to the page route. A refactor that moves an action can silently remove Proxy coverage. This is one more reason to check auth inside the action.

The deserialiser that decodes Server Function arguments is itself attack surface: the critical React Server Components vulnerability of December 2025 (CVE-2025-55182, CVSS 10.0) was in exactly this code path. Post 03 covers it.


Chapter 7 — Route Handlers

A route.ts file exports functions named after HTTP methods (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) and uses Web Request/Response. It cannot live in the same segment as a page.tsx.

1
2
3
4
5
6
7
8
9
10
11
// app/api/products/[id]/route.ts
import { NextRequest } from 'next/server'
import { getProductDTO } from '@/data/products'

export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params // a promise since 15
const product = await getProductDTO(id)
if (!product) return Response.json({ error: 'not found' }, { status: 404 })
return Response.json(product)
}

Caching by version: GET handlers were static by default in 14, dynamic by default in 15 (opt in with export const dynamic = 'force-static'), and in 16 with Cache Components they prerender when they touch no uncached or runtime data; move data access into a 'use cache' helper, because the directive cannot be put on the GET export itself.

When to use which:

Need Use
Mutation triggered from your own React UI Server Action
Webhook from a third party (Stripe, a CMS) Route Handler
Public or versioned API for mobile apps or other services Route Handler
Non-JSON responses (CSV export, RSS, images) Route Handler
Parallel reads from the client Route Handler (actions are sequential)
Reading data for a page Neither: fetch in the Server Component

Trick: do not call your own Route Handler with fetch from a Server Component. It adds a network hop and loses request context. Call the DAL function the handler uses.


Practice Exercises

Exercise 1 — Explain the stale price

A Next.js 15 page does await fetch('https://api.example.com/price') with no options and uses no request-time APIs. In production the price never changes. Why, and give two fixes.

Solution

The route has no dynamic API, so it is prerendered at build time; under auto no cache the fetch runs once during next build, and the value is frozen in the static HTML. Fixes: fetch(url, { next: { revalidate: 60 } }) (time-based ISR), or { cache: 'no-store' } / await connection() to render per request. With Cache Components in 16, put the fetch in a 'use cache' function with cacheLife('minutes'), or leave it uncached inside Suspense.

Exercise 2 — Cache-key bug

A developer writes a 'use cache' function getDashboard() that calls getCurrentUser() (which reads cookies) inside it. What happens, and what is the fix?

Solution

It fails with the next-request-in-use-cache error, because runtime APIs are forbidden inside cached scopes, including through helpers. Read the user outside and pass the id: getDashboard(user.id). The id then becomes part of the key, so each user gets their own entry. Without that rule, one cached dashboard could be served to every user.

Exercise 3 — Pick the invalidation API

For each case choose updateTag, revalidateTag(tag, 'max'), revalidatePath or refresh: (a) a user edits their display name and must see it immediately; (b) a CMS webhook says an article changed; (c) an action marks a notification read and the unread counter is uncached; (d) an admin action changes an untagged page.

Solution

(a) updateTag (read-your-own-writes, action only). (b) revalidateTag(tag, 'max') (Route Handler; updateTag is not allowed there). (c) refresh() (only uncached data changed). (d) revalidatePath('/that/page').

Exercise 4 — Secure the action

Review: export async function deleteComment(comment: { id: string; authorId: string }) { await db.comment.delete({ where: { id: comment.id } }) }. List every problem and rewrite it.

Solution

No authentication; no authorisation; it trusts authorId from the client; it accepts a whole object where an id suffices; no input validation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
'use server'
import { updateTag } from 'next/cache'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'

export async function deleteComment(commentId: string) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
if (typeof commentId !== 'string' || commentId.length > 64) throw new Error('Bad input')
const res = await db.comment.deleteMany({ where: { id: commentId, authorId: session.user.id } })
if (res.count === 0) throw new Error('Forbidden')
updateTag('comments')
}

Exercise 5 — Multi-instance failure

Three containers behind a load balancer intermittently log "Failed to find Server Action". Give two causes and the fixes.

Solution

(1) Each build generated its own closure-encryption key, or instances run different builds: set a shared NEXT_SERVER_ACTIONS_ENCRYPTION_KEY and deploy one build everywhere. (2) Version skew during a rolling deploy: a client from the old build calls an action ID the new build does not have. Configure deploymentId, prefer rolling deploys, and surface the error as a "refresh and retry" message.


Cheat-sheet

Task How
Parallel fetch await Promise.all([...]), or separate Suspense-wrapped components
Deduplicate any function per request React.cache(fn) with primitive arguments
Keep a module on the server import 'server-only'
Cache a fetch (14/15 model) fetch(url, { next: { revalidate: n, tags: [...] } })
Always fresh (15) { cache: 'no-store' } or await connection()
Turn on the 16 model cacheComponents: true
Cache a function or component (16) 'use cache' + cacheLife('hours') + cacheTag('x')
Default profile stale 5 min, revalidate 15 min, expire never
Runtime data and caching Read outside, pass plain values as arguments
Read-your-own-writes updateTag(tag) (Server Action only)
Background refresh revalidateTag(tag, 'max') (16 signature)
Refresh uncached UI only refresh()
Action body limit 1 MB default; serverActions.bodySizeLimit
Multi-instance actions Shared NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, deploymentId
Every action Authenticate, authorise, validate, return the minimum
Webhooks, public APIs, files Route Handler

What's next

Post 02 covers rendering strategies and runtimes: how a route becomes static or dynamic, streaming with Suspense and what it does to status codes, ISR and generateStaticParams, Partial Prerendering and how Cache Components builds a static shell with dynamic holes, proxy.ts (the renamed middleware), the Node.js versus Edge runtime decision, and the limits of a static export, using this blog's own build as the worked example. For the React primitives used here (useActionState, useOptimistic, transitions), see the React Deep Dive series.


References