Dynamic API Routes
Create dynamic API endpoints with path parameters, catch-all routes, and optional segments.
File Structure
Dynamic API routes match patterns instead of exact paths. Use square brackets in file or folder names - the file path determines the route.
/ API routes - the filename becomes the route segment. Write your Hono routes without the /api prefix.| Pattern | File/Folder Name | Matches |
|---|---|---|
| [id] | Single dynamic segment | /api/posts/123, /api/posts/abc |
| [category]/[slug] | Multiple dynamic segments | /api/posts/tech/hello-world |
| [...path] | Catch-all (required) | /api/files/a, /api/files/a/b/c |
| [[...slug]] | Catch-all (optional) | /api/docs, /api/docs/a/b |
Single Dynamic Parameter
Use [name] in the filename for a single dynamic segment:
With Hono
// src/app/api/posts/[id].ts -> /api/posts/:id
import { Hono } from 'hono'
const app = new Hono()
app.get('/posts/:id', (c) => {
const id = c.req.param('id')
return c.json({ id, title: `Post ${id}` })
})
app.put('/posts/:id', async (c) => {
const id = c.req.param('id')
const body = await c.req.json()
return c.json({ id, ...body })
})
export default appWith Plain Function
// src/app/api/posts/[id].ts -> /api/posts/:id
export default async function handler(request: Request) {
const paramsHeader = request.headers.get('x-bini-params')
const params = paramsHeader ? JSON.parse(paramsHeader) : {}
const id = params.id
if (request.method === 'GET') {
return Response.json({ id, title: `Post ${id}` })
}
return Response.json({ error: 'Method not allowed' }, { status: 405 })
}Multiple Dynamic Parameters
Combine multiple dynamic segments in a single route:
// src/app/api/posts/[category]/[slug].ts -> /api/posts/:category/:slug
import { Hono } from 'hono'
const app = new Hono()
app.get('/posts/:category/:slug', (c) => {
const category = c.req.param('category')
const slug = c.req.param('slug')
return c.json({ category, slug })
})
export default app| URL | params |
|---|---|
| /api/posts/tech/hello-world | { category: "tech", slug: "hello-world" } |
| /api/posts/lifestyle/tips | { category: "lifestyle", slug: "tips" } |
Catch-all Routes
Use [...name] in the filename to match any number of segments:
// src/app/api/files/[...path].ts -> /api/files/*
import { Hono } from 'hono'
const app = new Hono()
app.all('/files/:path*', (c) => {
const path = c.req.param('path') || ''
return c.json({ path, segments: path.split('/').filter(Boolean) })
})
export default app| URL | path value |
|---|---|
| /api/files | |
| /api/files/images | images |
| /api/files/images/2024 | images/2024 |
| /api/files/docs/api/reference | docs/api/reference |
Global Catch-all
// src/app/api/[...catch].ts -> /api/*
import { Hono } from 'hono'
const app = new Hono()
app.all('*', (c) => {
return c.json({ error: 'Not Found', path: c.req.path }, 404)
})
export default appOptional Catch-all
Use [[...name]] to make the catch-all optional:
// src/app/api/docs/[[...slug]].ts -> /api/docs or /api/docs/a/b
import { Hono } from 'hono'
const app = new Hono()
app.get('/docs/:slug*?', (c) => {
const slug = c.req.param('slug')
if (!slug) {
return c.json({ message: 'Documentation home' })
}
return c.json({ path: slug.split('/').filter(Boolean) })
})
export default app| URL | slug value |
|---|---|
| /api/docs | undefined (home page) |
| /api/docs/getting-started | getting-started |
| /api/docs/api/reference | api/reference |
Nested Dynamic Routes
Combine static and dynamic segments for complex routing:
// src/app/api/orgs/[orgId]/repos/[repoId]/issues/[issueId].ts
// -> /api/orgs/:orgId/repos/:repoId/issues/:issueId
import { Hono } from 'hono'
const app = new Hono()
app.get('/orgs/:orgId/repos/:repoId/issues/:issueId', (c) => {
const { orgId, repoId, issueId } = c.req.param()
return c.json({ orgId, repoId, issueId })
})
export default appQuery Parameters
Combine dynamic path parameters with query parameters:
// src/app/api/posts/[id]/comments.ts -> /api/posts/:id/comments
import { Hono } from 'hono'
const app = new Hono()
app.get('/posts/:id/comments', (c) => {
const postId = c.req.param('id')
const page = parseInt(c.req.query('page') || '1')
const limit = parseInt(c.req.query('limit') || '10')
return c.json({ postId, page, limit })
})
export default appRoute Priority
When multiple routes could match a URL, Bini.js resolves them in this order:
- Static routes - exact matches
- Dynamic single segments -
[id] - Catch-all segments -
[...slug] - Optional catch-all -
[[...slug]]
Complete Example
A full-featured store API with dynamic routing:
// src/app/api/store/[[...path]].ts -> /api/store or /api/store/*
import { Hono } from 'hono'
const app = new Hono()
const products = new Map()
app.get('/store', (c) => c.json({ products: Array.from(products.values()) }))
app.get('/store/products', (c) => c.json({ products: Array.from(products.values()) }))
app.get('/store/products/:id', (c) => {
const product = products.get(c.req.param('id'))
return product ? c.json(product) : c.json({ error: 'Not found' }, 404)
})
app.post('/store/products', async (c) => {
const body = await c.req.json()
const id = Date.now().toString()
const product = { id, ...body }
products.set(id, product)
return c.json(product, 201)
})
app.put('/store/products/:id', async (c) => {
const id = c.req.param('id')
if (!products.has(id)) return c.json({ error: 'Not found' }, 404)
const product = { ...products.get(id), ...(await c.req.json()) }
products.set(id, product)
return c.json(product)
})
app.delete('/store/products/:id', (c) => {
const id = c.req.param('id')
return products.delete(id)
? c.json({ message: 'Deleted' })
: c.json({ error: 'Not found' }, 404)
})
app.all('/store/*', (c) => c.json({ error: 'Not Found' }, 404))
export default app