Introduction
Static routes like /api/users only get you so far. To handle /api/users/123 or /api/posts/my-first-post, you need dynamic segments—placeholders in your route that capture URL values.
Key Concepts
Dynamic segments use bracket syntax:
[id]- Single dynamic segment (one path part)[...slug]- Catch-all segment (one or more parts)[[...slug]]- Optional catch-all (zero or more parts)
Real World Context
Dynamic routes power:
- Individual resource endpoints (
/api/users/:id) - Nested resources (
/api/posts/:postId/comments/:commentId) - File path handling (
/api/files/documents/report.pdf) - Flexible API versioning (
/api/v1/users,/api/v2/users)
Deep Dive
Single Dynamic Segment
typescript// app/api/users/[id]/route.ts export async function GET( request: Request, props: { params: Promise<{ id: string }> } ) { const { id } = await props.params; const user = await prisma.user.findUnique({ where: { id }, }); if (!user) { return Response.json({ error: 'User not found' }, { status: 404 }); } return Response.json(user); } // GET /api/users/abc123 → id = 'abc123'
Multiple Dynamic Segments
typescript// app/api/posts/[postId]/comments/[commentId]/route.ts export async function GET( request: Request, props: { params: Promise<{ postId: string; commentId: string }> } ) { const { postId, commentId } = await props.params; const comment = await prisma.comment.findFirst({ where: { id: commentId, postId: postId, }, }); return Response.json(comment); } // GET /api/posts/post-1/comments/comment-5
Catch-All Segments
typescript// app/api/files/[...path]/route.ts export async function GET( request: Request, props: { params: Promise<{ path: string[] }> } ) { const { path } = await props.params; const filePath = path.join('/'); return Response.json({ path: filePath, segments: path }); } // GET /api/files/documents/2024/report.pdf // → path = ['documents', '2024', 'report.pdf']
Optional Catch-All
typescript// app/api/docs/[[...slug]]/route.ts export async function GET( request: Request, props: { params: Promise<{ slug?: string[] }> } ) { const { slug } = await props.params; const segments = slug || []; if (segments.length === 0) { return Response.json({ page: 'index' }); } return Response.json({ page: segments.join('/') }); } // GET /api/docs → slug = undefined // GET /api/docs/intro → slug = ['intro'] // GET /api/docs/api/reference → slug = ['api', 'reference']
Type Safety
typescripttype RouteProps = { params: Promise<{ id: string; }>; }; export async function GET(request: Request, props: RouteProps) { const { id } = await props.params; // id is typed as string }
Common Pitfalls
-
Params are always strings: Even if the URL is
/api/users/123,params.idis'123'(string), not123(number). Parse if needed. -
Case sensitivity:
/api/Users/123and/api/users/123are different routes. Be consistent. -
Forgetting the folder:
[id]/route.tscreates the dynamic segment, not just naming the file[id].ts.
Best Practices
- Use descriptive names:
[userId]is clearer than[id]when you have multiple dynamic segments - Validate params: Check that IDs exist before querying the database
- Parse numeric IDs:
parseInt(params.id)when working with numeric database IDs - Use catch-all sparingly: Prefer explicit routes when possible
Summary
Dynamic segments capture URL values in your route handlers. Use [name] for single segments, [...name] for catch-all, and [[...name]] for optional catch-all. Params are always strings and accessed by awaiting props.params from the second argument to your handler function.