Dynamic route handlers are API endpoints whose path contains a placeholder segment, such as a post id or a user slug. You create one by putting a route file inside a folder whose name is wrapped in square brackets, and Next.js passes the captured value to your handler.
The value arrives in the second argument as a promise, so it has to be awaited before you can use it.
// app/api/posts/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
return Response.json({ id })
}A request to /api/posts/42 returns the JSON body {"id":"42"}. The folder name inside the brackets decides the property name, so a folder called [id] produces an id property and nothing else.
What the folder name maps to
The bracket style controls how many segments the route matches and what shape the captured value has. Values are strings, arrays of strings, or undefined for a missing optional catch-all value.
| Route file | Example URL | Resolved params |
|---|---|---|
| app/api/posts/[id]/route.ts | /api/posts/42 | { id: '42' } |
| app/api/shop/[...slug]/route.ts | /api/shop/tops/tees | { slug: ['tops', 'tees'] } |
| app/api/shop/[[...slug]]/route.ts | /api/shop | { slug: undefined } |
| app/api/[team]/[member]/route.ts | /api/core/ada | { team: 'core', member: 'ada' } |
An ellipsis inside the brackets makes the segment catch-all, so it matches one or more segments and gives you an array. Doubling the brackets makes it optional, which means the bare parent path matches too and the value is undefined.
Awaiting params correctly
Since the argument is a promise, destructuring it directly will not give you the value. Await first, then use what comes back.
// app/api/posts/[id]/route.ts
import { getPost } from '@/lib/posts'
type Context = { params: Promise<{ id: string }> }
export async function GET(request: Request, ctx: Context) {
const { id } = await ctx.params
const post = await getPost(id)
if (!post) return new Response('Not found', { status: 404 })
return Response.json(post)
}A known id returns the post as JSON, and an unknown one returns a 404 with a plain text body. Naming the context type separately keeps the signature readable once a handler takes both the request and the params. Reading a property before resolving the promise is invalid in Next.js 16, which is covered in fixing "params should be awaited".
The value you get back is always a string, even when the segment looks like a number. Anything that depends on the shape of that value, such as a database lookup by numeric id, should validate or convert it before use. A visitor can type any path into the address bar, so an endpoint that assumes a well-formed id will fail on the first bad request.
Route params are separate from the query string. A request to /api/posts/42?draft=true still gives you only the id, and the draft value has to be read from the URL. The distinction is explained in params vs searchParams.
Typing the context with RouteContext
Writing the promise type by hand works, but Next.js generates a helper that derives it from the route literal. RouteContext is global after types are generated, so no import is needed for it.
// app/api/users/[id]/route.ts
import { type NextRequest } from 'next/server'
export async function GET(
request: NextRequest,
context: RouteContext<'/api/users/[id]'>
) {
const { id } = await context.params
return Response.json({ id })
}Types are generated during next dev, next build, or next typegen, so the helper is unavailable until one of those has run at least once. The benefit over a hand-written type is that renaming the folder becomes a type error instead of a silent mismatch between the path and the property name.
Prerendering a known set of endpoints
Ordinary Route Handlers run at request time by default in Next.js 15 and 16. When the set of valid paths is known ahead of time, export generateStaticParams from the same file and Next.js builds those responses at build time.
// app/api/posts/[id]/route.ts
export async function generateStaticParams() {
const posts = await fetch('https://api.vercel.app/blog').then((res) =>
res.json()
)
return posts.map((post: { id: number }) => ({ id: `${post.id}` }))
}Every id returned here is generated at build time and served as a static response, while any other id is still handled dynamically on request. The values must be strings, which is why the number is interpolated.
With Cache Components enabled, cache data used by these responses deliberately with use cache; generateStaticParams chooses paths, but it is not a general data-cache directive. More patterns are in generateStaticParams explained.
Placement rules that decide whether it works
A dynamic endpoint depends entirely on where the file sits, so these are worth checking before debugging the code.
- The route file must be named route.ts or route.js, inside the bracketed folder, not next to it.
- A route file and a page file cannot share the same route, since each takes over all HTTP methods for that path.
- The property name comes from the folder name, so renaming the folder renames the param.
- Nested dynamic folders produce several params at once, one per bracketed segment.
Common mistakes
- Treating the params argument as a plain object and reading a property without awaiting it.
- Expecting a single bracket to match nested paths. Use catch-all brackets when the depth varies.
- Assuming an optional catch-all always gives an array. On the bare parent path the value is undefined, so guard before mapping over it.
- Reaching for route params when the value is really a query string.
The short version
Wrap a folder name in brackets, put a route file inside it, and await the params promise in the second handler argument. Use catch-all brackets for variable depth, the RouteContext helper for typing, and generateStaticParams when the paths are known at build time. The basics of the file itself are covered in route handlers explained.
Rune AI
Key Insights
- A bracketed folder containing route.ts creates a dynamic endpoint.
- The second handler argument carries params as a promise, so await it.
- Single brackets give a string, catch-all segments give an array of strings.
- The RouteContext helper types params from the route literal after type generation.
- Query strings are not route params, read them from nextUrl.searchParams.
Frequently Asked Questions
Why is params a promise in a Route Handler?
Can a route file and a page file share the same dynamic segment?
How do I read a query string instead of a path segment?
Do dynamic route handlers run on every request?
Conclusion
A dynamic Route Handler is a route file inside a bracketed folder, and its second argument carries a params promise you must await. Use a single bracket for one segment, an ellipsis for catch-all paths, and generateStaticParams when a known set of endpoints should be prerendered.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.