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.

File-based routing: Like all Bini.js API routes, dynamic routes follow file-based routing. There are no root / API routes - the filename becomes the route segment. Write your Hono routes without the /api prefix.
app
api
posts
ƒ[id].ts
users
[userId]
ƒsettings.ts
files
ƒ[...path].ts
ƒ[...catch].ts
/api/posts/:id
/api/users/:userId/settings
/api/files/*
/api/*
PatternFile/Folder NameMatches
[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
// 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 app

With Plain Function

src/app/api/posts/[id].ts
// 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
// 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
URLparams
/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
// 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
URLpath value
/api/files
/api/files/imagesimages
/api/files/images/2024images/2024
/api/files/docs/api/referencedocs/api/reference

Global Catch-all

src/app/api/[...catch].ts
// 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 app

Optional Catch-all

Use [[...name]] to make the catch-all optional:

src/app/api/docs/[[...slug]].ts
// 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
URLslug value
/api/docsundefined (home page)
/api/docs/getting-startedgetting-started
/api/docs/api/referenceapi/reference

Nested Dynamic Routes

Combine static and dynamic segments for complex routing:

src/app/api/orgs/[orgId]/repos/[repoId]/issues/[issueId].ts
// 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 app

Query Parameters

Combine dynamic path parameters with query parameters:

src/app/api/posts/[id]/comments.ts
// 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 app

Route Priority

When multiple routes could match a URL, Bini.js resolves them in this order:

  1. Static routes - exact matches
  2. Dynamic single segments - [id]
  3. Catch-all segments - [...slug]
  4. Optional catch-all - [[...slug]]
app
api
posts
ƒfeatured.ts
ƒ[id].ts
ƒ[...slug].ts
/api/posts/featured
/api/posts/:id
/api/posts/*
Routes are sorted by priority and then by path length (shortest first). Static routes always win over dynamic ones.

Complete Example

A full-featured store API with dynamic routing:

src/app/api/store/[[...path]].ts
// 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
Was this helpful?