API Routes Overview
Backend endpoints with plain Request handlers or Hono. Files under app/api/ map to /api/*.
Overview
Place files in src/app/api/ and they become routes at /api/*. hello.ts maps to /api/hello, users.ts to /api/users.
default export. Named exports are not used as handlers.File Structure
The filename (without extension) becomes the last path segment under /api/. API files use the ƒ icon.
/api route. Use posts/index.ts for /api/posts.Plain Function Handler
A default-exported async function receives the Web Standard Request and returns a Response.
import { z } from 'zod'
import { requireEnv } from 'bini-env'
const BodySchema = z.object({
name: z.string().min(1).max(100),
})
export default async function handler(request: Request) {
if (request.method !== 'GET' && request.method !== 'POST') {
return Response.json({ error: 'Method not allowed' }, { status: 405 })
}
const auth = request.headers.get('Authorization')
if (!auth || !auth.startsWith('Bearer ')) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const expectedToken = requireEnv(request as any, 'API_SECRET')
if (auth !== `Bearer ${expectedToken}`) {
return Response.json({ error: 'Forbidden' }, { status: 403 })
}
if (request.method === 'GET') {
return Response.json(
{ message: 'Hello World' },
{ headers: { 'Cache-Control': 'no-store' } }
)
}
const raw = await request.json().catch(() => null)
const parsed = BodySchema.safeParse(raw)
if (!parsed.success) {
return Response.json(
{ error: 'Invalid body', issues: parsed.error.flatten() },
{ status: 400 }
)
}
return Response.json({ created: parsed.data }, { status: 201 })
}Hono Integration
Export a Hono app as the default export. Write routes without the /api prefix - the router mounts them under /api.
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
import { requireEnv } from 'bini-env'
const app = new Hono()
app.use(
'*',
cors({
origin: ['https://myapp.com', 'https://app.myapp.com'],
allowMethods: ['GET', 'POST'],
allowHeaders: ['Authorization', 'Content-Type'],
})
)
app.use('*', async (c, next) => {
const auth = c.req.header('Authorization')
const secret = requireEnv(c as any, 'API_SECRET')
if (!auth || auth !== `Bearer ${secret}`) {
return c.json({ error: 'Unauthorized' }, 401)
}
await next()
})
const CreateSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
})
app.get('/users', (c) => {
return c.json(
{ users: [{ id: '1', name: 'alice' }] },
200,
{ 'Cache-Control': 'no-store' }
)
})
app.post('/users', zValidator('json', CreateSchema), async (c) => {
const body = c.req.valid('json')
return c.json({ created: body }, 201)
})
app.get('/users/:id', (c) => {
const id = c.req.param('id')
if (!/^\d+$/.test(id)) {
return c.json({ error: 'Invalid id' }, 400)
}
return c.json({ id, name: `User ${id}` })
})
export default appHono Middleware
Prefer an explicit CORS allowlist, security headers, and rate limiting.
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { secureHeaders } from 'hono/secure-headers'
const app = new Hono()
app.use(
'*',
secureHeaders({
contentSecurityPolicy: { defaultSrc: ["'self'"] },
})
)
app.use(
'*',
cors({
origin: ['https://myapp.com'],
allowMethods: ['GET', 'POST'],
credentials: true,
})
)
app.get('/secure', (c) => {
return c.json(
{ message: 'Authenticated endpoint' },
200,
{ 'Cache-Control': 'no-store' }
)
})
export default app| Middleware | Purpose |
|---|---|
| cors (allowlist) | CORS - never open origin in production |
| secureHeaders | CSP and related headers |
| rate limiter | Throttle abusive clients |
| auth / jwt | Verify Bearer tokens |
Dynamic API Routes
Use [id] folders or files for path params. Validate params before use.
import { Hono } from 'hono'
import { z } from 'zod'
const app = new Hono()
app.get('/posts/:id', (c) => {
const id = c.req.param('id')
const parsed = z.string().regex(/^\d+$/).safeParse(id)
if (!parsed.success) {
return c.json({ error: 'Invalid id format' }, 400)
}
return c.json(
{ id: parsed.data, title: `Post ${parsed.data}` },
200,
{ 'Cache-Control': 'private, max-age=60' }
)
})
export default appCatch-all API Routes
[...catch] matches remaining unmatched /api/* paths. Prefer a generic 404 without leaking internal paths.
export default function handler(request: Request) {
console.warn('Unmatched API route', { method: request.method })
return Response.json(
{
error: 'Not Found',
message: 'The requested endpoint does not exist',
},
{
status: 404,
headers: { 'Cache-Control': 'no-store' },
}
)
}Environment Variables
Use getEnv and requireEnv from bini-env so secrets work across runtimes.
import { Hono } from 'hono'
import { getEnv, requireEnv } from 'bini-env'
const app = new Hono()
app.post('/email', async (c) => {
const ctx = c as any
const smtpHost = requireEnv(ctx, 'SMTP_HOST')
const smtpPass = requireEnv(ctx, 'SMTP_PASS')
const smtpPort = parseInt(getEnv(ctx, 'SMTP_PORT') ?? '587', 10)
return c.json({ success: true, host: smtpHost, port: smtpPort })
})
export default appconst ctx = c as any
requireEnv(ctx, 'KEY')
getEnv(ctx, 'KEY') ?? 'default'Request & Response
Prefer safe JSON parsing and schema validation for query and body values.
import { z } from 'zod'
const QuerySchema = z.object({
page: z.coerce.number().int().min(1).max(100).default(1),
})
export default async function handler(request: Request) {
const auth = request.headers.get('Authorization')
if (!auth) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const rawJson = await request.json().catch(() => null)
if (!rawJson) {
return Response.json({ error: 'Invalid JSON' }, { status: 400 })
}
const { searchParams } = new URL(request.url)
const queryParsed = QuerySchema.safeParse({
page: searchParams.get('page') ?? '1',
})
if (!queryParsed.success) {
return Response.json({ error: 'Invalid query' }, { status: 400 })
}
return Response.json(
{ data: rawJson, page: queryParsed.data.page },
{ headers: { 'Cache-Control': 'no-store' } }
)
}CORS
Use an explicit origin allowlist in production. Avoid open cors().
import { defineConfig } from 'vite'
import { biniroute } from 'bini-router'
export default defineConfig({
plugins: [
biniroute({
cors: {
origin: ['https://myapp.com', 'https://app.myapp.com'],
methods: ['GET', 'POST'],
credentials: true,
},
}),
],
})import { Hono } from 'hono'
import { cors } from 'hono/cors'
const app = new Hono()
app.use(
'*',
cors({
origin: (origin) => {
const allowed = ['https://myapp.com', 'https://app.myapp.com']
return allowed.includes(origin ?? '') ? origin : null
},
allowMethods: ['GET', 'POST'],
allowHeaders: ['Authorization', 'Content-Type'],
credentials: true,
maxAge: 86400,
})
)
export default appDeployment
API routes work across platforms. bini-deploy generates the platform entry files.
$ npm run deploy
- Node.js - bini-server
- Netlify - Edge Functions
- Vercel - Edge Runtime
- Cloudflare - Workers
- Deno - Deno Deploy