One Folder, Any Value: How [id] Absorbs a URL Segment
A product page lives at /products/42 today and /products/43 tomorrow. You are not creating a folder per id, and you are not writing a route table either: in the App Router you name the folder [id], and that one folder becomes a pattern matching any value in that position of the URL.
app/products/
page.tsx -> /products the listing
new/page.tsx -> /products/new a literal segment
[id]/page.tsx -> /products/42, /products/abc, /products/anything
The mechanics are worth stating exactly, because interviews probe them. The router splits the incoming pathname on / and walks the folder tree one segment at a time. A folder whose name is wrapped in brackets accepts exactly one segment and captures it under the key written inside the brackets: [id] yields id, [productId] yields productId. Nothing is registered anywhere — there is no :id syntax, no routes file to import. next build prints the pattern (/products/[id]) instead of the concrete URLs it covers, which is how you confirm the folder was picked up.
| Request | Winning folder | Result |
|---|---|---|
/products/42 |
[id] |
renders with id = "42" |
/products |
products/page.tsx |
listing; [id] never fires |
/products/new |
new/page.tsx |
literal beats dynamic |
/products/42/edit |
none | no folder absorbs the extra segment, so 404 |
/products/a/b |
none | 404, same reason |
That fourth row is the one that surprises people. A single dynamic folder absorbs a single segment, so /products/42/reviews needs its own folder: app/products/[id]/reviews/page.tsx. The third row is deliberate — static segments win over dynamic ones at the same depth, so /products/new stays a stable URL even though [id] could match the string new. When two equally specific patterns could both match (say products/[id]/page.tsx beside products/[slug]/page.tsx), Next warns during the build about an ambiguous route. You fix that by picking one folder name, not by reordering files.
Dynamic folders nest freely and each contributes one key:
// app/shop/[category]/[product]/page.tsx
export default async function Page({ params }: {
params: Promise<{ category: string; product: string }>;
}) {
const { category, product } = await params; // "books", "9780134685991"
return <ProductPage category={category} product={product} />;
}
Two request-shaping details matter in real apps. Values arrive decoded: a browser requesting /products/red%20shoes hands you id === "red shoes". But a value containing an encoded slash (red%2Fshoes) can never be captured, because splitting happens before decoding — the router already saw two segments. And traversal-looking values such as %2e%2e decode to .. after matching, so treating a param as a file path needs its own sanitizing layer.
A dynamic folder also decides how your page is rendered. Because the set of possible values is unbounded, the route is dynamic by default: each new id renders on the server at request time. Prerendering a handful of known ids with generateStaticParams flips them to static files while everything else still renders per request, and <Link> to a dynamic route prefetches only that route's loading fallback in production rather than live data. So adding a bracket folder changes two things at once — the URL pattern and the caching posture of the page.
Client-side navigation uses the same matching rules. <Link href={/products/${p.id}}> never mentions the bracket folder; the router resolves the href against the filesystem when you click, and a mismatched href (say /products/ with no segment) fails the same way a typed URL does — with a 404, not an exception. That is a useful debugging habit: if a link renders a 404 but the URL looks right, the folder tree is missing a segment, and next build's route table will show you which pattern actually exists.
Interviewers often ask what happens on a nonsense id like /products/abc. The honest answer has two parts: the router matches anything non-empty and hands it over untouched, and everything after that — parsing, existence checks, the 404 — is application code. That guard is what the previous topic Props Serialization Constraints and the next one, Reading Route Params, set you up to write.
Where the Matched Value Lands: Reading params in Pages, Layouts, and Client Code
The folder matches the URL; the params prop delivers it. A page.tsx receives route data as a prop object whose keys are exactly the bracket names on the path to that page, and in current Next.js releases params is a Promise you await before use.
// app/products/[id]/page.tsx
export default async function ProductPage(props: {
params: Promise<{ id: string }>;
}) {
const { id } = await props.params; // await first, read second
const product = await db.product.findUnique({ where: { id } });
if (!product) notFound();
return <ProductDetail product={product} />;
}
Why async at all? Making the value a Promise lets the segment stream: the page can suspend on data while the layout above it has already been flushed to the browser, and the router can resolve params without blocking the whole render. The cost is mechanical — forget the await and you render [object Promise] or crash on id.toLowerCase(). TypeScript catches this if you type the prop, which is why the Promise<...> annotation belongs in the signature rather than a cast afterwards.
Layouts receive params too, scoped to their own segment. app/products/[id]/layout.tsx gets { id } for the product it wraps, while app/products/layout.tsx gets an empty object — a layout only sees keys from folders at or below its level. That scoping is what lets a tab layout render params.id without knowing anything about deeper segments such as reviews.
Not every file takes params as a prop. Each execution context has its own shape:
| Context | How you read the segment |
|---|---|
| Server page / layout | const { id } = await props.params |
| Client component | useParams() from next/navigation |
Route handler (route.ts) |
const { id } = await context.params |
| Middleware | request.nextUrl.pathname, split yourself |
Watch what happens when you pass params down to a client component: you must serialize it like any other prop, so a nested client component receives id as an ordinary string prop. Rules about what may cross that boundary are the subject of Props Serialization Constraints — the practical consequence here is that reading useParams() inside the client component avoids threading the value through three levels of props.
useParams() returns a plain object (not a Promise) and is the only sane option in a client component, because a client component has no server prop to await:
'use client';
import { useParams } from 'next/navigation';
export function CopyButton() {
const { id } = useParams<{ id: string }>();
return <button onClick={() => navigator.clipboard.writeText(id)}>Copy ID</button>;
}
The same prop name appears in generateStaticParams, but there it is a return value: you return an array of { id } objects for the paths to prerender at build time. Pairs like app/products/[id]/page.tsx returning [{ id: '42' }] give you a static HTML file for product 42 and on-demand rendering for everything else — unless you set export const dynamicParams = false, which turns every unlisted id into a 404.
Three smaller uses of the same promise are worth knowing before an interviewer stacks them on you. generateMetadata receives params and must await it too, so the document title can include the id (title: Order ${id}). A not-found.tsx in the dynamic folder can read the id with useParams() if your 404 copy needs it. And awaiting params alone does not force dynamic rendering — a route prerendered via generateStaticParams has already resolved its params at build time; it is awaiting searchParams, uncached data, or headers that flips a page to per-request rendering.
Interviewers like that contrast: params is how a request tells you its values, generateStaticParams is how you tell the builder its values, and useParams() is how client code re-reads them without a prop.
Before the Query: Validating Params So /products/abc Never Reaches the Database
The router will match /products/abc, /products/0x7f000001, and /products/%2e%2e%2fetc. Matching is pattern work, not validation: it guarantees a segment exists and nothing else. If your page immediately runs db.product.findUnique({ where: { id } }), a junk URL becomes a Postgres error (invalid input syntax for type integer: "abc") rendered as a 500, plus a noisy trace. Interviewers ask how you prevent that, and the expected answer is a guard at the top of the page, before any I/O.
Start with the shape check:
// app/orders/[id]/page.tsx
const ULID = /^[0-9A-HJKMNP-TV-Z]{26}$/;
export default async function OrderPage(props: {
params: Promise<{ id: string }>;
}) {
const { id } = await props.params;
if (!ULID.test(id)) notFound(); // malformed -> 404, no query issued
const order = await db.order.findUnique({ where: { id } });
if (!order) notFound(); // well-formed but missing
return <OrderDetail order={order} />;
}
Two notFound() calls, two different failures. The first rejects a value that could never be a real id — wrong length, wrong alphabet, wrong type. The second accepts the format and reports that no such record exists. Collapsing them into one branch loses information: a malformed id is a client bug or a crawler artifact, while a valid-but-absent id is a normal 404. Keeping them separate also means your logs can tell you whether someone is probing your API with garbage.
Structured parsing beats regex when the param carries more than an identifier. Query-string and multi-field inputs are the obvious case, but even path segments sometimes encode a composite: /report/2024/05 modelled as app/report/[...period]/page.tsx should be parsed with a schema, not by indexing the array:
import { z } from 'zod';
const Period = z.object({
year: z.coerce.number().int().min(2000).max(2100),
month: z.coerce.number().int().min(1).max(12),
});
const parsed = Period.safeParse({
year: props.params.period[0],
month: props.params.period[1],
});
if (!parsed.success) notFound();
z.coerce.number() matters because every param is a string — "05" is not 5, and loose comparisons (id == 5) silently coerce in ways that break on "05" versus "5". Normalize deliberately: trim, decide whether leading zeros are significant, and pick one canonical representation before it touches a key.
Another common hole: an id that is valid-looking but wrong-typed passes a length check and then hits a column of a different type. If your primary keys are cuid-like strings but one legacy table still uses autoincrement integers, findUnique({ where: { id: "999999999999999999" } }) can overflow or throw a driver error rather than returning null. The defense is to make the schema authoritative — declare the param type once and parse against it everywhere, so a single source of truth decides what "an id" means:
// lib/params.ts
export const ProductId = z.string().regex(/^p_[a-z0-9]{12}$/);
export function readProductId(raw: string): string | null {
const parsed = ProductId.safeParse(raw);
return parsed.success ? parsed.data : null;
}
Unit-testing that helper takes a table of cases (p_abc123def456 accepts, P_ABC rejects, empty string rejects, 13-character string rejects) and needs no router at all — which is exactly the point of pulling validation out of the component. The page stays a thin shell: await params, call the helper, query, render.
Finally, treat params as untrusted for anything resembling a path or URL. decodeURIComponent runs for you, so ..%2F..%2Fsecrets arrives as ../../secrets — never concatenate it into fs.readFile; resolve against a base directory and reject results that escape it. The same applies to values you later echo into a redirect() or a Link: an attacker-supplied id shaped like //evil.example/x becomes a protocol-relative URL in some browsers.
The rule interviewers want to hear: validate at the boundary, in the page, before the first await that costs something, then pass the parsed, typed value onward. Everything downstream can then assume id is a well-formed product key instead of defending itself at every query, every serialization step, and every template interpolation. That is the difference between "the router matched it" and "the application accepts it", and conflating the two is where most 500s in production Next.js apps come from.
Quiz
1. Both app/products/new/page.tsx and app/products/[id]/page.tsx exist. A request arrives for /products/new. Which page renders?
2. app/products/[id]/page.tsx exists but app/products/[id]/reviews/page.tsx does not. What happens on /products/42/reviews?
3. A browser requests /products/red%20shoes and /products/red%2Fshoes. What does the page receive?
4. Your page runs a database query using params.id directly and /products/abc returns a 500 with 'invalid input syntax for type integer'. What is the correct fix?
Flashcards
Question
What does a bracket folder like [id] match, and what key does it produce?
Click to reveal answer
Answer
Exactly one URL segment at that position, with any value; the captured value is delivered under the key written inside the brackets ([id] -> params.id, [productId] -> params.productId). It never matches an empty position, so /products with no segment does not hit products/[id].
Question
When two folder patterns could match the same URL, which one wins?
Click to reveal answer
Answer
The more specific one: literal segments beat dynamic segments at the same depth (/products/new goes to new/, not [id]). Equally specific patterns (products/[id] vs products/[slug]) are ambiguous and produce a build-time warning; the fix is to rename one folder, not to reorder files.
Question
Are params decoded, and what happens to a value containing an encoded slash?
Click to reveal answer
Answer
Yes — values arrive URL-decoded ('red%20shoes' -> 'red shoes'). An encoded slash (%2F) cannot be captured at all because the router splits the pathname on '/' before decoding, so the request becomes two segments and no single [id] folder matches it.
Question
Where is params read in a page, in a client component, and in a route handler?
Click to reveal answer
Answer
Server page/layout: await props.params (a Promise). Client component: useParams() from next/navigation (a plain object). Route handler: await context.params. Middleware: parse request.nextUrl.pathname yourself. Each execution context has its own shape.
Question
What is the standard guard pattern before using a dynamic param in a query?
Click to reveal answer
Answer
Validate at the top of the page, before any I/O: check the format (regex or a Zod schema with coercion) and call notFound() on failure, then query and call notFound() again if the record is missing. Malformed input never reaches the data layer, so junk URLs return 404 instead of a 500.
Revision Notes
Key Takeaways
- 1. A bracket folder matches exactly one URL segment and yields a key named after the folder; it never matches an empty position
- 2. Literal segments win over dynamic ones at equal depth; equally specific patterns are a build-time ambiguity, not a runtime race
- 3. params is a Promise in server pages and layouts, a plain object from useParams() in client code, and a Promise on route handler context
- 4. Every param arrives as a decoded string — coercion, leading zeros, and encoded slashes are your problem to handle
- 5. Validate the format at the top of the page and call notFound() before the first query, so malformed URLs never reach the data layer
Interview Tips
- • "What does [id] match?" — exactly one segment, captured under that key; add the precedence rule (literal beats dynamic) and the fact that /products/42/edit 404s because nothing absorbs the extra segment
- • "Are route params decoded?" — yes, %20 becomes a space, but %2F can never be captured because splitting precedes decoding; this shows you have debugged real URLs
- • "How do you stop bad input reaching my database?" — guard at the boundary: parse with a schema, notFound() on failure, query only the typed value; distinguish malformed from missing for cleaner logs
- • "Dynamic vs static rendering for a dynamic route" — generateStaticParams lists the ids to prerender, everything else renders on demand, and dynamicParams=false turns unlisted ids into 404s
Cheat Sheet
Dynamic Route Segments
Folder -> pattern: [id] matches one segment, captured as params.id. No route config; next build prints /products/[id].
Matching rules:
| Request | Winner | Why |
|---|---|---|
/products/42 |
[id] |
single segment captured |
/products |
products/page.tsx |
dynamic folder needs a segment |
/products/new |
new/page.tsx |
literal beats dynamic at same depth |
/products/42/edit |
— (404) | no folder absorbs the extra segment |
Equally specific patterns -> build warning about ambiguity; rename one folder.
Reading the value:
// server page/layout
const { id } = await props.params; // params is a Promise
// client component
const { id } = useParams<{ id: string }>(); // plain object
// route handler
const { id } = await context.params;
Layouts see only the keys at or below their own segment.
Build-time control:
export async function generateStaticParams() {
return [{ id: '42' }]; // prerender these paths
}
export const dynamicParams = false; // unlisted ids -> 404
Validation guard (before any I/O):
const { id } = await props.params;
if (!ULID.test(id)) notFound();
const row = await db.find(id);
if (!row) notFound();
Params are strings ("05" !== 5) and arrive decoded — never concatenate one into a file path without resolving against a base and checking for escape.