How a route declares itself dynamic
How a Next.js 16 route signals request-time rendering through async request APIs and connection(), and how to migrate the legacy segment config exports they replace.
Say you inherit a page.tsx that opens with four lines of config declaring how the route renders, and in the same sprint you ship a page that reads a ?status= filter, the session cookie, and a fresh request ID.
Both tasks turn on one question: what makes a route render at request time instead of at build, and how is that decision declared in the source?
Earlier this chapter we established that routes are dynamic by default and that you carve out cached subtrees with 'use cache', but never how a route announces its request-time work in the first place.
The old answer was a set of config exports that are now going away; the new one is the async request APIs that replace them.
This lesson covers both, so you can migrate any older route you inherit and write the request-reading shape correctly on each side of the server/client boundary.
Route segment config: the legacy way to set render mode
Section titled “Route segment config: the legacy way to set render mode”Before Next.js 16, a route’s render disposition came from two places.
Some of it was inferred: read cookies() in a Server Component, or run an uncached fetch, and Next.js flipped the whole route to dynamic.
The rest was declared: you exported a few special constants at the top of the module to override that inference.
Those exports set how the entire route below them rendered.
Four of them turn up often enough that you need to recognize them, though you will not write them yourself.
export const dynamic = 'force-dynamic';export const revalidate = 60;export const fetchCache = 'force-cache';export const runtime = 'edge';Together these are called route segment config .
dynamic ('auto' | 'force-dynamic' | 'force-static' | 'error') forced the rendering mode outright.
revalidate, in seconds, set the window for ISR , re-generating the page on a timer.
fetchCache set the caching policy for every fetch in the segment.
runtime ('nodejs' | 'edge') picked the runtime the route ran on.
This config is being retired because it was implicit and route-global.
One export at the top set the disposition for the entire subtree, so a component nested five levels deep could call a dynamic API and silently contradict what its ancestor had declared, with no way to tell from the child which mode it was in.
Cache Components moves the decision to where it takes effect: a per-component 'use cache' directive and a per-read await.
The change is enforced, not optional.
Under cacheComponents: true, the dynamic, revalidate, and fetchCache exports are rejected with a build error that points you at the migration.
Migrating legacy route config exports
Section titled “Migrating legacy route config exports”You will inherit routes that still carry these exports, and a build error that says “remove this” is only half an instruction. The other half is what to replace it with. The mapping below is the official one from the Next.js 16 migration guide.
'use cache' with cacheLife('max'); drop any request-time reads — those now need a <Suspense> boundary, which contradicts "fully static". 'use cache' + cacheLife('hours') — pick the preset closest to the interval, not a raw second count. 'use cache' scope every fetch is cached; outside one, nothing is. The directive subsumes it. proxy.ts. dynamic = 'force-dynamic' and fetchCache = 'force-cache' simply disappear, because Cache Components already decides those structurally: dynamic is the default, and caching follows the 'use cache' boundary rather than a fetch-level policy.
runtime = 'edge' has no equivalent at all.
Node is the runtime and the right default for a 2026 web app; logic that must run before the request reaches your route moves to proxy.ts, covered in the next chapter.
The fetchCache row reflects a deeper change.
Before 16, fetch() cached by default and you opted out with { cache: 'no-store' }.
In 16 that default flips: fetch() is no-store, and you opt in by wrapping the call in a 'use cache' function.
Caching is now something you reach for on purpose.
const res = await fetch('https://api.example.com/rates', { cache: 'no-store',});fetch() cached by default, so you reached for { cache: 'no-store' } to force a fresh call.
async function getRates() { 'use cache'; const res = await fetch('https://api.example.com/rates'); return res.json();}fetch() is no-store by default. To cache, wrap the call in a 'use cache' scope.
The one row that is not a clean delete is revalidate, because it carries a real value you translate rather than discard.
export const revalidate = 3600;
export default async function BlogPage() { const posts = await getPosts(); return <PostList posts={posts} />;}The old route revalidated the whole page on a one-hour timer via a module-scope export.
async function getPosts() { 'use cache'; cacheLife('hours'); const posts = await db.posts.findPublished(); return posts;}
export default async function BlogPage() { const posts = await getPosts(); return <PostList posts={posts} />;}The timer moves to the data read. Reach for the cacheLife preset closest to the interval, 'hours' here, rather than a hand-rolled { revalidate: 3600 } profile.
You will not do most of this by hand.
Running npx @next/codemod@canary upgrade latest strips the dead exports, renames middleware.ts to proxy.ts, and removes the old experimental_ppr flag.
What it cannot do is read intent: it leaves you the revalidate translation above to finish.
The tool does the typing; you supply the judgment.
Match each legacy export on the left to its correct migration action on the right.
Match each legacy route segment config export to the correct migration under Cache Components. Click an item on the left, then its match on the right. Press Check when done.
export const dynamic = 'force-dynamic'export const revalidate = 60'use cache' + cacheLife('minutes') at the data read.export const fetchCache = 'force-cache''use cache' instead.export const runtime = 'edge'proxy.ts for edge logic.Request data arrives as a Promise
Section titled “Request data arrives as a Promise”The migration table looks backward, at the code you inherit. Now look forward, at the code you write. If the config exports are gone, what signals that a route does request-time work? One contract covers every piece of request data, and it is worth memorizing as a sentence:
params, searchParams, cookies(), headers(), and draftMode() all return Promises in Next.js 16. You await them in a Server Component and unwrap them with React.use() in a Client Component. Synchronous access is gone: a sync read is a build error.
Five APIs, one access pattern.
In Next.js 15, reading cookies() synchronously was the implicit magic that flipped a route to dynamic, so the disposition changed and nothing in the source showed it.
Making these APIs async makes that moment visible: the await is the signal.
Under Cache Components, where dynamic is already the default, the await no longer flips anything.
It confirms the route reads request-time data, and it marks the one thing a 'use cache' scope is forbidden to contain.
The async style is not an annoyance to work around; it is the explicit signal the whole model is built on.
Start with the two you have already met.
In the chapter on file-system routing, a dynamic segment like [id] delivered its value through props.params, and the query string arrived through props.searchParams.
In 16, both are Promises you await.
export default async function InvoicePage(props: { params: Promise<{ id: string }>; searchParams: Promise<{ tab?: string }>;}) { const { id } = await props.params; const { tab } = await props.searchParams;
return <InvoiceDetail invoiceId={id} activeTab={tab ?? 'summary'} />;}The page is async, and both props are typed as Promises. That type is the contract: there is no synchronous version to reach for.
export default async function InvoicePage(props: { params: Promise<{ id: string }>; searchParams: Promise<{ tab?: string }>;}) { const { id } = await props.params; const { tab } = await props.searchParams;
return <InvoiceDetail invoiceId={id} activeTab={tab ?? 'summary'} />;}Awaiting params unwraps the route’s dynamic segments. This await is the dynamic signal: it tells the framework that this render reads request-time data. Validate constrained params right at the read site with a Zod safeParse, such as z.coerce.number() or z.uuid().
export default async function InvoicePage(props: { params: Promise<{ id: string }>; searchParams: Promise<{ tab?: string }>;}) { const { id } = await props.params; const { tab } = await props.searchParams;
return <InvoiceDetail invoiceId={id} activeTab={tab ?? 'summary'} />;}searchParams is the URL query, and its shape is easy to get wrong. Each value is a string, a string[] for a repeated key like ?tag=a&tag=b, or undefined when the key is absent. The tab ?? 'summary' fallback handles the missing case.
export default async function InvoicePage(props: { params: Promise<{ id: string }>; searchParams: Promise<{ tab?: string }>;}) { const { id } = await props.params; const { tab } = await props.searchParams;
return <InvoiceDetail invoiceId={id} activeTab={tab ?? 'summary'} />;}Once awaited, the values are plain data you pass into the tree like any other prop.
The two pieces from next/headers, cookies() and headers(), have the same shape.
You import and call them instead of reading them off props: const cookieStore = await cookies(); then cookieStore.get('session')?.value.
That await is the same dynamic signal.
This is the await-and-access mechanics only; the real usage of cookies and headers, including session reads and trust boundaries on proxied headers, comes in the next chapter.
import { cookies, headers } from 'next/headers';
const readContext = async () => { const cookieStore = await cookies(); const session = cookieStore.get('session')?.value; const userAgent = (await headers()).get('user-agent'); return { session, userAgent };};That leaves draftMode().
It returns a Promise that resolves to { isEnabled, enable, disable }, the toggle a CMS preview uses to show unpublished content.
You reach for it when you wire up a content source.
With it, the list of request APIs is closed at five.
Reading request data in a Client Component with React.use()
Section titled “Reading request data in a Client Component with React.use()”Everything so far assumed a Server Component, where await is available.
Client Components cannot be async, so how does one read searchParams?
You have met this pattern at the server/client boundary: a Server Component starts an async read without awaiting it, passes the unresolved Promise down as a prop, and the Client Component unwraps it with React.use() .
Here the parent page passes the still-unresolved searchParams Promise down, and the client does const { status } = use(searchParams).
Because use() suspends until the Promise resolves, the consuming component, or one of its ancestors, must sit inside a <Suspense> boundary: the fallback shows while the Promise is pending, then swaps to the resolved content.
This is where the pattern gets overused.
Default to awaiting on the server and passing the resolved plain value down.
Reach for the Promise plus use() only when a Client Component genuinely owns the read, for instance because it must re-read on a client-side interaction.
export default async function InvoicesPage(props: { searchParams: Promise<{ status?: string }>;}) { const { status } = await props.searchParams; return <StatusBadge status={status ?? 'all'} />;}The page awaits searchParams on the server and hands the child a plain string, so the child stays a simple synchronous component. Right for almost every case.
import { Suspense } from 'react';
export default async function InvoicesPage(props: { searchParams: Promise<{ status?: string }>;}) { return ( <Suspense fallback={<StatusSkeleton />}> <StatusFilter searchParams={props.searchParams} /> </Suspense> );}'use client';import { use } from 'react';
export const StatusFilter = (props: { searchParams: Promise<{ status?: string }>;}) => { const { status } = use(props.searchParams); return <StatusBadge status={status ?? 'all'} />;};When the read belongs to a Client Component, pass the unresolved Promise down and unwrap it with use(). It must sit under a <Suspense> boundary, since use() suspends until the Promise resolves.
connection(): marking a render dynamic without a request API
Section titled “connection(): marking a render dynamic without a request API”The framework infers a route is dynamic from that closed set of awaited request APIs. But some work must run per request while touching none of them, and static analysis, seeing no signal, prerenders it at build and bakes in whatever value it computed there. You need a way to declare everything below a given point dynamic.
That marker is await connection(), imported from next/server.
It marks the current render dynamic on its own.
Place it before the per-request work, and that work is guaranteed to run at request time.
Three situations are canonical. Learn to recognize the shape of the problem, not just the API:
- Reading
process.envat runtime. Next.js 16 reads runtime configuration straight fromprocess.env. Inside a prerenderable scope that read can freeze at build, so callawait connection()beforeprocess.env.RUNTIME_FLAGto force a genuine runtime read. - Non-determinism.
Date.now()for a freshness stamp, orMath.random()andcrypto.randomUUID()for a per-request ID. The'use cache'lesson warned these freeze inside a cached scope;connection()is the mirror image, forcing them to re-evaluate on every request. - Third-party SDK calls that read ambient state lazily and leave no awaited-API footprint for the framework to detect.
import { connection } from 'next/server';
export default async function ConfigBanner() { await connection(); const flag = process.env.RUNTIME_FLAG; return <Banner enabled={flag === 'on'} />;}Two things to remember.
First, connection() inside a 'use cache' function is a build error, the same rule that governs request APIs: you cannot cache something whose whole purpose is to differ per request.
Second, this is a rare and precise tool.
Most routes never need it, because they already await a real request API and are dynamic for that reason.
Reaching for connection() should make you pause and confirm there is genuinely no other signal, and that you are not papering over a value that should have been an argument or a real request read.
Props typed from your route folders
Section titled “Props typed from your route folders”Look back at the page signatures in this lesson and you’ll spot a recurring chore: props: { params: Promise<{ id: string }> }, hand-written on every page.
It’s boilerplate, and it drifts.
Rename the [id] folder to [invoiceId] and the type still says id, now wrong, and TypeScript trusts it anyway.
Next.js generates these types from your actual route folders instead.
It exposes three globally available helpers, PageProps<'/route'>, LayoutProps<'/route'>, and RouteContext<'/route'>, with no import needed.
PageProps<'/blog/[slug]'> gives you params: Promise<{ slug: string }> and the matching searchParams shape, derived from the route’s real structure.
Rename the folder and the type follows.
export default async function InvoicePage(props: PageProps<'/invoices/[id]'>) { const { id } = await props.params; return <InvoiceDetail invoiceId={id} />;}These types come from next typegen , which runs as part of dev and build, so the types are usually just there as you work.
They’re only as fresh as the last run, though.
Right after you add or rename a route, a stale type or a “route not found” error from PageProps means one thing: re-run npx next typegen, or let the dev server pick it up.
Putting it together
Section titled “Putting it together”An experienced engineer doesn’t memorize five APIs; they run a short decision the moment a component needs a value. Pick the branch that matches your situation and see where it lands.
const { id } = await props.params;. The await is the dynamic signal: it confirms the render reads request-time data. This is the default for almost every read.
The parent server component passes the unresolved Promise down; the client does use(promise). Reach for this only when the Client Component genuinely owns the read; otherwise await on the server and pass the resolved value.
The explicit dynamic marker for request-time work the framework can’t otherwise detect. Rare and precise: confirm there’s truly no other signal first. It’s a build error if placed inside a 'use cache' scope.
A value shared across requests belongs in a cached scope, not a dynamic read. Pick the cacheLife preset that matches the data’s shape, and tag it for invalidation. This is the other half of the chapter, not this lesson.
Remove dynamic and fetchCache; translate revalidate to 'use cache' plus the closest cacheLife preset; take out runtime = 'edge', since Node is the default and edge logic moves to proxy.ts.
Is this even dynamic, is it request data or ambient work, which side owns the read: that order is the reflex worth building.
External resources
Section titled “External resources”The official upgrade guide, including the async request APIs and the removed segment-config exports.
The canonical source for the legacy-to-new migration table this lesson is built on.
The explicit dynamic marker for request-time work the framework can't otherwise detect.
The globally available, route-typed props helpers and the command that generates them.