Dynamic Route Handlers and Route Params

How to build an API endpoint with a dynamic segment, read the route params promise, and type the context object in the App Router.

7 min read

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.

typescripttypescript
// 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 fileExample URLResolved 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.

typescripttypescript
// 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.

typescripttypescript
// 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.

typescripttypescript
// 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

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.
RunePowered by Rune AI

Frequently Asked Questions

Why is params a promise in a Route Handler?

Route params became asynchronous in Next.js 15, and synchronous access was removed in Next.js 16. In Next.js 14 it was a plain object, and an official codemod exists for upgrading.

Can a route file and a page file share the same dynamic segment?

No. A route file and a page file cannot sit at the same route, because each one takes over every HTTP method for that path. Give the endpoint its own folder, such as an api prefix.

How do I read a query string instead of a path segment?

Query values are not part of params. Read them from the request URL with nextUrl.searchParams, which is available on the NextRequest object passed as the first argument.

Do dynamic route handlers run on every request?

Ordinary Route Handlers are dynamic by default in Next.js 15 and 16. You can prerender known paths with generateStaticParams, and Cache Components uses use cache for data you deliberately cache.

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.