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.

Every API file must use a 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.

app
api
ƒhello.ts
ƒusers.ts
posts
ƒindex.ts
ƒ[id].ts
ƒ[...catch].ts
/api/hello
/api/users
/api/posts
/api/posts/:id
/api/*
There is no bare /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.

src/app/api/hello.ts
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 })
}
Validate input, require auth, and prefer typed errors. For many endpoints, Hono is usually clearer.

Hono Integration

Export a Hono app as the default export. Write routes without the /api prefix - the router mounts them under /api.

src/app/api/users.ts
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 app

Hono Middleware

Prefer an explicit CORS allowlist, security headers, and rate limiting.

src/app/api/secure.ts
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
MiddlewarePurpose
cors (allowlist)CORS - never open origin in production
secureHeadersCSP and related headers
rate limiterThrottle abusive clients
auth / jwtVerify Bearer tokens

Dynamic API Routes

Use [id] folders or files for path params. Validate params before use.

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

Catch-all API Routes

[...catch] matches remaining unmatched /api/* paths. Prefer a generic 404 without leaking internal paths.

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

src/app/api/email.ts
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 app
const 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().

vite.config.ts
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,
      },
    }),
  ],
})
src/app/api/cors.ts
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 app

Deployment

API routes work across platforms. bini-deploy generates the platform entry files.

>_Terminal
$ npm run deploy
  • Node.js - bini-server
  • Netlify - Edge Functions
  • Vercel - Edge Runtime
  • Cloudflare - Workers
  • Deno - Deno Deploy
Was this helpful?