Awaiting the Route: Why params Is a Promise and What Breaks When You Skip It
The symptom is familiar: you destructure { id } from the props, render <h1>Order {id}</h1>, and the page prints Order [object Promise]. Or you call id.toLowerCase() and get TypeError: id.toLowerCase is not a function. Nothing is wrong with your folder name — in the App Router, params is a Promise, and route data is delivered asynchronously on purpose.
// app/orders/[id]/page.tsx
export default async function OrderPage(props: {
params: Promise<{ id: string }>;
}) {
const { id } = await props.params; // one await, then it is a string
return <h1>Order {id}</h1>;
}
Why the indirection? Because a Promise can be handed to the renderer before it resolves. The layout above your page can flush its HTML while the page's params (and the data derived from it) is still pending, so the segment participates in streaming instead of blocking the whole tree. A synchronous object would force the router to know every value up front, which is precisely what a dynamic URL cannot promise. The same reasoning produced searchParams as a Promise — one rule, both props, no special case to remember.
Typing the prop is not ceremony. Annotating params: Promise<{ id: string }> gives TypeScript the resolved shape, so const { id } = await props.params yields a string and a mistyped key (param.id) fails at compile time. Without the annotation, props.params is any, await still works at runtime, and the only warning you get is a rendered Promise in production HTML.
Layouts await it exactly the same way, scoped to their own segment:
// app/orders/[id]/layout.tsx
export default async function Layout(props: {
params: Promise<{ id: string }>;
children: React.ReactNode;
}) {
const { id } = await props.params;
const order = await getOrder(id); // one fetch shared by all child pages
return <OrderShell order={order}>{props.children}</OrderShell>;
}
That pattern earns its keep: the layout's fetch runs once per navigation and every page beneath it reuses the result, so tab pages stop duplicating a getOrder call. Note that children is already-rendered output — you never await it.
Two practical rules follow. First, await early: resolve params as the first statement so a bad value fails fast, before you have started a transaction or rendered half a tree. Second, parallelize independent reads — const [order, items] = await Promise.all([getOrder(id), getItems(id)]) — otherwise two 150 ms queries serialize into 300 ms on every request. This is also where you decide to call notFound() for an unknown id, before touching anything else.
Failure handling is worth one more pass, because the await changes where errors surface. A rejected params Promise (or a notFound() thrown after it) propagates to the nearest error boundary above the page — your error.tsx, or app/error.tsx at the root — exactly like a failed fetch would. It does not crash the server and it does not produce a partial page: React discards the pending branch and renders the fallback in its place. That means validating id immediately after the await gives you a clean 404 path, while validating after three queries gives you three wasted round trips and a stack trace naming your ORM.
Watch the ordering inside a layout with children: awaiting params before rendering props.children delays all of them. If the shell does not need the id, render children first and await in a sibling component instead — the router renders children independently, so their awaits are not serialized behind the layout's.
The pattern repeats in every async entry point. generateMetadata receives params and must await it for a dynamic title; a route handler awaits context.params; generateStaticParams returns values instead of reading them. Same shape, opposite direction: pages receive what the URL matched, the builder receives what you promised to prerender. Up next, Catch-All and Optional Segments changes the shape of what arrives — an array instead of a string.
Pagination, Filters, and Defaults: Reading searchParams Without Lying to the Router
/orders?page=3&status=open&status=shipped — now the URL carries state. The App Router delivers the query string through the searchParams prop, and it follows the same async pattern as params: a Promise you await, with values shaped by how the query string itself is shaped.
export default async function OrdersPage(props: {
searchParams: Promise<{
page?: string;
status?: string | string[];
}>;
}) {
const sp = await props.searchParams;
const page = Number(sp.page ?? '1');
const status = Array.isArray(sp.status) ? sp.status : [sp.status ?? 'all'];
return <OrdersTable page={page} status={status} />;
}
Three rules cover most bugs. Every value is a string — ?page=3 arrives as '3', so page + 1 becomes '31' and page < 10 compares lexicographically; coerce explicitly. A repeated key becomes an array (status=open&status=shipped -> ['open','shipped']) while a single occurrence stays a string, so one code path must normalize both — that Array.isArray branch above is not optional. A key with no value (?draft) arrives as '', which is falsy but present, so sp.draft ?? 'no' yields '', not 'no'.
Defaults deserve a deliberate reading. page = Number(sp.page ?? '1') gives NaN for ?page=abc; clamp instead of trusting:
const raw = Number(sp.page ?? '1');
const page = Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 1;
const pageSize = 20; // never take size from the URL unbounded
That last line is a real production lesson: letting clients pass ?size=100000 turns your endpoint into a denial-of-service tool. Fix a maximum, floor the value, and keep the same rule whether the filter arrived in the path or the query.
Now the part interviewers actually care about. Awaiting searchParams opts the route into dynamic rendering. The URL of a server component is not known at build time, so a page that reads the query cannot be prerendered — next build will mark it ƒ and every visit runs it. That is correct for a paginated table and wasteful for a marketing page that only wanted ?ref= for analytics. If the query affects nothing in the HTML, read it elsewhere: a client component can use useSearchParams() from next/navigation, which returns the parsed values without turning the server render into a per-request one:
'use client';
import { useSearchParams } from 'next/navigation';
export function RefBadge() {
const ref = useSearchParams().get('ref'); // null when absent
return ref ? <span>via {ref}</span> : null;
}
The trade: useSearchParams() must live below a Suspense boundary or the component deopts to client-side rendering until the URL is known, and the value on the server is unavailable during static generation. So the decision is simply who needs it — HTML generated on the server needs await props.searchParams and pays the dynamic-render bill; a client-side embellishment reads the URL in the browser for free.
One more skill interviewers look for: updating the query without a full navigation. Filters live in the URL so they survive refresh and sharing, which means changing one has to rewrite history. From a client component, useRouter().replace('/orders?' + params.toString()) swaps the query without stacking an entry per checkbox toggle, while router.push would bury the back button under twenty states. From a server component you cannot call the router at all — render a <Link href={...}> for each filter instead, and let the router's client-side transition do the work. The rule of thumb: replace for state the user is refining in place, push for a genuinely new destination.
Read searchParams in the page, not in a shared layout, unless the layout truly varies by query — a layout that awaits searchParams flips every descendant route dynamic, which is a much larger blast radius than the one paginated table you meant to affect.
The Cost of Knowing: Which Reads Force Per-Request Rendering and Where to Put the Boundary
A route has three ways to learn a value: the folder matched (params), the query string (searchParams), or something only the server knows at request time (cookies, headers, an uncached fetch). Each read has a different price, and mixing them casually is how a page that "should be static" ends up rebuilding on every hit.
| Read | Known at build time? | Effect on rendering |
|---|---|---|
await props.params after generateStaticParams |
Yes — values were listed | Stays static for the listed paths |
await props.params with no static list |
Only at request time | Dynamic for that segment |
await props.searchParams |
Never | Route becomes dynamic (ƒ) |
cookies() / headers() / no-store fetch |
Never | Route becomes dynamic |
useSearchParams() in a client component |
N/A (client read) | Server render unchanged, needs Suspense |
The subtle entry is the first row. Awaiting params does not inherently make a page dynamic — if generateStaticParams returned [{ id: '42' }], the value was baked into HTML at build time and there is nothing left to wait for. What makes a segment dynamic is not knowing the value until a request arrives. That is why the blanket claim "params forces dynamic rendering" is only half true, and why an interviewer who probes it is testing whether you understand the model rather than the syntax.
searchParams has no such escape hatch: the query string is a property of an individual visit. So the engineering move is to isolate the read instead of letting it drag the page down. Split the tree — keep the shell static, put the query-dependent table inside a Suspense boundary:
// app/orders/page.tsx — this file can still prerender its shell
export default function Page() {
return (
<main>
<h1>Orders</h1> {/* static: no searchParams read */}
<Suspense fallback={<TableSkeleton />}>
<OrderTable /> {/* awaits searchParams inside */}
</Suspense>
</main>
);
}
OrderTable is an async server component that awaits props.searchParams; the boundary lets the static shell stream first and the query-dependent part resolve after. Visitors without a query string still get a fast, cacheable page, and search engines see the headings immediately.
Common failure modes to be able to name out loud. Rendering params without awaiting produces [object Promise]; passing the Promise itself to a client component throws a serialization error at the boundary; reading searchParams in a layout you expected to be shared and static flips every descendant to dynamic. Debugging starts with next build's route table: ○ means the route was prerendered, ƒ means it renders per request — and ƒ appearing where you expected ○ is your cue to ask which read crossed the line.
Route (app) Type Status
/ ○ prerendered as static
/orders ƒ SSR
/orders/[id] ƒ SSR (no generateStaticParams)
/orders/[id]/invoice ○ prerendered (100 paths listed)
That output is the fastest evidence you can bring to a review: instead of claiming "the page feels dynamic", you show the row. When a route must stay static despite a request-only read — the emerging pattern is partial prerendering, where the static shell is emitted first and dynamic holes fill in behind it — the split you built with Suspense is exactly what makes it possible. The static part ships from the CDN; only the query-dependent branch waits for the origin.
The durable takeaway: static is a property you protect deliberately. Every new read is a claim that this value differs per request, and the framework believes you. Make the claim where it is true, isolate it where it is not, and verify with the route table rather than with intuition.
Quiz
1. A page renders the literal text `Order [object Promise]` in production. What is the cause and the fix?
2. The URL is /orders?page=3&status=open&status=shipped. What does `await props.searchParams` return?
3. A page with generateStaticParams awaits `props.params` for a path that was listed. What does `next build` report for that route?
4. You only need `?ref=` to show a UTM badge in the header, and the page must stay statically prerendered. What is the right approach?
Flashcards
Question
Why is `params` a Promise in App Router pages and layouts, and what is the visible symptom of not awaiting it?
Click to reveal answer
Answer
Async props let the renderer flush ancestor layouts before the segment's values (and data derived from them) resolve, so dynamic segments stream instead of block. Not awaiting prints '[object Promise]' in HTML or throws TypeError when you call a string method on it. Fix: `const { id } = await props.params` with the prop typed as `Promise<{ id: string }>`.
Question
What shapes can a searchParams value take, and what are the three parsing traps?
Click to reveal answer
Answer
Every value is a string; a repeated key yields string[]; an empty-value key yields ''. Traps: arithmetic on unparsed strings ('3' + 1 === '31'), assuming string|string[] is always one shape, and `??` defaults that miss empty-string keys. Normalize with Array.isArray, Number(), and finite/range clamping.
Question
Which reads force per-request (dynamic) rendering, and which do not?
Click to reveal answer
Answer
Force dynamic: awaiting searchParams, cookies()/headers(), fetches with cache: 'no-store' or no explicit cache plus cookies access. Do NOT force dynamic: awaiting params when generateStaticParams listed the path (resolved at build), and useSearchParams() in a client component under Suspense. The determinant is whether the input is knowable at build time.
Question
Await searchParams server-side vs useSearchParams() in a client component — when do you pick each?
Click to reveal answer
Answer
Server: needed when the query affects server-rendered HTML (pagination, filters) — accurate, but the route becomes dynamic (ƒ). Client: fine for URL-driven UI state (highlight active tab, badge, scroll) — leaves the server render alone, returns null when absent, and must sit below a Suspense boundary.
Question
How do you debug a route that turned dynamic when you expected it static?
Click to reveal answer
Answer
Read the `next build` route table: ○ = prerendered, ƒ = per-request. Then bisect the tree for the request-only read — searchParams, cookies, headers, or an uncached fetch — and isolate it behind a Suspense boundary so the shell prerenders. generateStaticParams only covers params, never the query string.
Revision Notes
Key Takeaways
- 1. params and searchParams are both Promises — await them first, type the props as Promise<{...}>, and never render the Promise itself
- 2. Query values are always strings; repeated keys become arrays; empty-value keys become '' — normalize before any arithmetic or comparison
- 3. Awaiting params after generateStaticParams stays static because the builder already knew the values; unknown-at-build input is what costs
- 4. Awaiting searchParams opts the route into per-request rendering — isolate it behind Suspense so the shell still prerenders
- 5. The build output's ○/ƒ flags are the ground truth for what became dynamic; chase the read, not the syntax
Interview Tips
- • "Why are params async?" — route values must not block ancestor layouts; a Promise streams with the tree, which is the same reason searchParams is async; follow-up: not awaiting prints [object Promise]
- • "Does reading params force dynamic rendering?" — no, not if generateStaticParams listed the path; explain that the determinant is build-time knowability, and name the reads that do force ƒ (searchParams, cookies, headers, no-store)
- • "Paginated list — how do you parse searchParams safely?" — String|string[] normalization, Number() with isFinite and range clamping, a fixed max page size, and defaults that also handle empty-string keys
- • "How would you keep the page static but use the query?" — move the read into a client component with useSearchParams() under Suspense, or split the server tree so only the query-dependent child awaits it
Cheat Sheet
Reading Route Params
Both route props are Promises:
export default async function Page(props: {
params: Promise<{ id: string }>;
searchParams: Promise<{ page?: string; status?: string | string[] }>;
}) {
const { id } = await props.params;
const sp = await props.searchParams;
}
Skip the await -> [object Promise] in HTML or id.toLowerCase is not a function.
Query-string parsing rules:
| Input | Arrives as |
|---|---|
?page=3 |
'3' (always a string) |
?status=a&status=b |
['a','b'] (array) |
?draft |
'' (present but empty) |
Clamp numbers (Number.isFinite, min 1, max page size) — never forward raw values to the DB.
Rendering cost:
| Read | Effect |
|---|---|
params + listed generateStaticParams |
stays ○ static |
params unknown at build |
ƒ for that segment |
searchParams awaited |
route is ƒ |
cookies() / headers() / no-store |
route is ƒ |
useSearchParams() in client code |
server render untouched (needs Suspense) |
Split the tree when only part needs the query:
<Suspense fallback={<TableSkeleton />}>
<OrderTable /> {/* async, awaits searchParams here */}
</Suspense>
Also async: generateMetadata({ params }), route handler context.params, generateStaticParams (returns values, does not read them).