How a route declares itself dynamic
How a Next.js 16 route signals it renders at request time, through async request APIs and connection, and how to migrate the legacy route segment config exports they replace.
Picture your first week on a new team. You open a page.tsx and the very first thing you see, before any component, is four lines of configuration declaring how this route is supposed to render. In the same sprint you have to ship a new page that reads a ?status= filter from the URL, the user’s session cookie, and a freshly generated request ID for logging. These two tasks look unrelated, but they turn on the same question: what makes this route render at request time instead of at build, and how is that decision declared in the source?
The model we built at the start of this chapter, dynamic by default, answered most of the questions but left this one piece implicit. We said a route is dynamic by default and that you carve out cached subtrees with 'use cache', but we never said how a route announces that it is doing request-time work in the first place. This lesson closes that loop. It has two halves, and they are the same question viewed from two eras. The first half is the legacy config exports that used to declare a route’s disposition and are now going away. The second is the async request APIs that replace them as the real signal. By the end you will be able to read and migrate any older route you inherit, and write the new request-reading shape correctly on both sides of the server/client boundary.
The config exports that used to color a route
Section titled “The config exports that used to color a route”Before Next.js 16, a route’s render disposition came from two sources mixed together. Some of it was inferred: read cookies() in a Server Component, or run a fetch without caching it, and Next.js quietly flipped the whole route to dynamic. The rest was declared: you exported a handful of special constants at the top of the module to override the inference. Those exports acted as a per-route control panel that sat above your component and set how the entire route below it rendered.
Four of them turn up often enough to matter. You do not need to learn to write these, only to recognize them.
export const dynamic = 'force-dynamic';export const revalidate = 60;export const fetchCache = 'force-cache';export const runtime = 'edge';Taking them in order: this family is called route segment config . dynamic ('auto' | 'force-dynamic' | 'force-static' | 'error') forced the route’s rendering mode directly. revalidate, a number of seconds, set the window for ISR , re-generating the page on a timer. fetchCache set the caching policy for every fetch in the segment. And runtime ('nodejs' | 'edge') picked which runtime the route executed on.
This control panel is being retired for the same reason that has run through this whole chapter: it was implicit and route-global. One export at the top changed the disposition of the entire subtree below it, so a component nested five levels deep could reach for a dynamic API and silently contradict the setting its ancestor had declared. There was no way to look at that deep child and know what render mode it was actually in. Cache Components drops the panel and replaces it with two things you read and write at the exact spot they apply: a per-component 'use cache' directive, and a per-read await. The decision now lives where it takes effect rather than far above it.
This is a firm rule, not a soft deprecation you can leave in place and ignore. Under cacheComponents: true, the dynamic, revalidate, and fetchCache exports are rejected: Next.js raises a build error that points you at the migration. You cannot ship a route that still declares them.
Reading legacy routes: the migration table
Section titled “Reading legacy routes: the migration table”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, and it is worth keeping close, because you will come back to it every time you open an older codebase.
'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. A few of these rows reward a closer look. dynamic = 'force-dynamic' and fetchCache = 'force-cache' simply disappear, because Cache Components already makes those decisions structurally: dynamic is the default, and caching follows the 'use cache' boundary rather than a fetch-level policy. runtime = 'edge' has no equivalent under Cache Components at all. Node is the runtime, and it is also the right default for a 2026 SaaS app. When you genuinely need edge behavior, meaning logic that runs before the request reaches your route, that work moves to proxy.ts, which gets its own lesson in the next chapter.
The fetchCache row points at a deeper change worth stating outright, because the old habit is muscle memory for a lot of developers. Before 16, fetch() cached its result by default and you opted out with { cache: 'no-store' }. In 16 that default is flipped: fetch() is no-store by default, and you opt in by wrapping the call inside a 'use cache' function. This is the same explicit-by-default story as everything else in the chapter, where caching is something you reach for on purpose rather than something you trip over.
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 directive is where the opt-in now lives.
The one row that is not a clean delete is revalidate, because it carries a real value you have to translate rather than discard. It is also the row people get wrong most often, so look at it on its own.
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. As the earlier lesson on cache lifetimes and tags established, you match a preset to the data’s shape rather than to an exact second count.
You will not do most of this by hand. Running npx @next/codemod@canary upgrade latest performs the bulk of the migration mechanically: it strips the dead exports, renames middleware.ts to proxy.ts, and removes the old experimental_ppr flag. But the codemod cannot read intent, so it will leave you with the result above to understand and finish. That is why the table matters: the tool does the typing, and you supply the judgment.
The exercise below lets you fix the mapping in your memory. 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”That was the half that looks backward, at the code you inherit. The other half looks forward, at the code you write. If the config panel is gone, what actually signals that a route is doing request-time work? The answer is a single contract that covers every piece of request data, and it is worth memorizing as one sentence:
params, searchParams, cookies(), headers(), and draftMode() all return Promises in Next.js 16. You await them in a Server Component, and you unwrap them with React.use() in a Client Component. Synchronous access is gone: a sync read is a build error.
That is the whole shape: five APIs, one access pattern. The reason it works this way ties back to the chapter’s spine. In Next.js 15, reading cookies() synchronously was the implicit magic that silently flipped a route to dynamic, so the disposition changed and nothing in the source showed it. Making these APIs async makes that dynamic moment visible, because the await is the signal. Under Cache Components, where dynamic is already the default, the await no longer flips anything. It simply confirms the route is doing request-time work, and it marks the one thing a 'use cache' scope is forbidden to contain. So 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. Back 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 of these 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 here at the read site with a Zod safeParse, for example z.coerce.number() or z.uuid(). The full pattern lands later in the course.
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. This is the canonical URL-state read; the filter and pagination patterns built on it come in the next chapter.
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. Nothing exotic happens after the unwrap.
The two pieces from next/headers, cookies() and headers(), have the exact same shape. You just 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. We are teaching only the await-and-access mechanics here. The real SaaS usage of cookies and headers, including session reads, trust boundaries on proxied headers, and the read-once-at-the-top pattern, is covered in depth 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() to round out the set. It returns a Promise that resolves to { isEnabled, enable, disable }, the toggle a CMS preview uses to show unpublished content. You will reach for it when you wire up a content source; naming it here just confirms the list of request APIs is closed at five. There is no hidden sixth channel.
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. But Client Components cannot be async, because React will not let a component that runs on the client return a Promise. So how does a Client Component read searchParams when it cannot await it?
You have already solved this exact problem. Back in the chapter on the server/client boundary, the pattern was: a Server Component starts an async read but does not await it, passes the unresolved Promise down as a prop, and the Client Component unwraps it with React.use() . This is the same primitive, not a new one. The parent server page passes the still-unresolved searchParams Promise down, and the client does const { status } = use(searchParams).
Because use() suspends the component until the Promise resolves, the component that consumes it, or one of its ancestors, has to sit inside a <Suspense> boundary. That is the same seam this whole chapter leans on, and the same one that drives streaming from the previous chapter: the boundary’s fallback shows while the Promise is pending, then swaps to the resolved content.
The judgment call comes next, because this is where the pattern gets overused. Your default should be to await on the server and pass the resolved plain value down. Reach for passing the Promise plus use() only when a Client Component genuinely owns that read, for instance because it needs to re-read on a client-side interaction. Most of the time the server already has the value, so hand it down resolved.
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. The child stays a simple, synchronous component. This is the right choice 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(). The component must sit under a <Suspense> boundary, since use() suspends until the Promise resolves.
connection(): declaring dynamic when the framework can’t see it
Section titled “connection(): declaring dynamic when the framework can’t see it”There is a gap in everything we just described. The framework infers that a route is dynamic from that closed set of awaited request APIs. But some work genuinely must run per request while touching none of them, and the framework’s static analysis, seeing no signal, will try to prerender it at build and bake in whatever value it computed there. You need a way to tell the framework that everything below a given point is dynamic.
That marker is await connection(), imported from next/server. It marks the current render dynamic with no other dependency. Place it before the work that must run per request, and that work is guaranteed to run at request time.
Three situations are the canonical ones. Aim to recognize the shape of the problem, not just the API:
- Reading
process.envat runtime. Next.js 16 removed the oldserverRuntimeConfigandpublicRuntimeConfigmechanism, so runtime configuration is now read straight fromprocess.env. But inside a prerenderable scope, that read could be frozen at build. Callingawait connection()beforeprocess.env.RUNTIME_FLAGforces a genuine runtime read. - Non-determinism.
Date.now()for a freshness stamp, orMath.random()andcrypto.randomUUID()for a per-request ID. These are exactly the values the earlier'use cache'lesson warned would freeze if you put them inside a cached scope.connection()is the mirror image: it forces 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'} />;}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 are worth remembering. First, the same rule that governs request APIs governs this one: connection() inside a 'use cache' function is a build error. Request-time work and cached output are mutually exclusive, because you cannot cache something whose whole purpose is to differ per request. Second, and more important for your judgment, 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 just papering over a value that should have been an argument or a real request read.
Typed props without the boilerplate
Section titled “Typed props without the boilerplate”Look back at the page signatures in this lesson and you will notice a recurring chore: props: { params: Promise<{ id: string }> }, hand-written on every page. That annotation is boilerplate, and worse, it drifts. Rename the [id] folder to [invoiceId] and the type still says id. The annotation is now wrong, and TypeScript trusts it anyway.
Next.js solves this by generating the types for you from your actual route folders. It exposes three globally available helpers, PageProps<'/route'>, LayoutProps<'/route'>, and RouteContext<'/route'>, with no import needed. PageProps<'/blog/[slug]'> hands 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 are generated by next typegen , which runs as part of dev and build, so most of the time the types are simply there while you work. The one catch is that they are only as fresh as the last run. Right after you add or rename a route, a stale type or a “route not found” error from PageProps means just one thing: re-run npx next typegen, or let the dev server pick it up. That is the normal loop, not a bug. Knowing this helper is what keeps a route’s prop types correct as the folders move under them.
Putting it together
Section titled “Putting it together”Step back, and the lesson is really one question asked in a deliberate order. An experienced engineer does not memorize five APIs; they run a short decision the moment a component needs a value. Walk through it below: 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.
That order is the reflex worth building: is this even dynamic, is it request data or ambient work, and which side of the boundary owns it. The legacy config and the async APIs were never two separate topics. They are two answers to that same first question, one from the era that is ending and one from the era you are building in.
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.