Dynamic API Routes

Learn how to create dynamic API endpoints with path parameters, catch-all routes, and optional segments.

Dynamic API routes allow you to create endpoints that match patterns rather than exact paths. Use square brackets in your file names to define dynamic segments — 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.

File Structure

Dynamic segments are created using square brackets in file or folder names:

src/app/api/
├── posts/
│   └── [id].ts              → /api/posts/:id
├── users/
│   └── [userId]/
│       └── settings.ts      → /api/users/:userId/settings
├── files/
│   └── [...path].ts         → /api/files/a/b/c (catch-all)
└── [...catch].ts            → /api/* (global catch-all)
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]]
src/app/api/posts/
├── featured.ts           → /api/posts/featured (static — matched first)
├── [id].ts               → /api/posts/123 (dynamic — matched second)
└── [...slug].ts          → /api/posts/a/b/c (catch-all — matched last)
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