Plain Function Handlers

Simple API endpoints with a default-exported Request → Response function.

Plain function handlers are the simplest way to create API routes. Ideal for single endpoints that do not need Hono middleware or nested routing.

File-based routing: src/app/api/hello.ts is served at /api/hello. There is no bare /api root route.

Basic Handler

Export a default function that receives Request. The file path sets the route - the function name does not matter.

src/app/api/hello.ts
export default function handler(req: Request) {
  return Response.json({ message: 'hello', method: req.method })
}

This creates /api/hello and responds to all HTTP methods unless you branch on request.method.

Route Mapping

File structure maps directly to API paths:

app
api
ƒhello.ts
ƒuser.ts
ƒposts.ts
posts
ƒindex.ts
ƒ[id].ts
ƒ[...catch].ts
/api/hello
/api/user
/api/posts
/api/posts
/api/posts/:id
/api/*
File PathAPI Route
src/app/api/hello.ts/api/hello
src/app/api/user.ts/api/user
src/app/api/posts.ts/api/posts
src/app/api/posts/[id].ts/api/posts/:id
src/app/api/posts/index.ts/api/posts
src/app/api/[...catch].ts/api/*

Handling HTTP Methods

Branch on request.method for different verbs:

src/app/api/posts.ts
export default function handler(request: Request) {
  if (request.method === 'GET') {
    return Response.json({ posts: [] })
  }
  if (request.method === 'POST') {
    return Response.json({ message: 'Post created' }, { status: 201 })
  }
  if (request.method === 'PUT') {
    return Response.json({ message: 'Post updated' })
  }
  if (request.method === 'DELETE') {
    return Response.json({ message: 'Post deleted' })
  }
  return Response.json({ error: 'Method not allowed' }, { status: 405 })
}
MethodTypical Use
GETRetrieve data
POSTCreate new data
PUTReplace existing data
PATCHPartially update data
DELETERemove data

Reading Request Data

Body, headers, and query params:

src/app/api/echo.ts
export default async function handler(request: Request) {
  const body = await request.json().catch(() => null)
  const userAgent = request.headers.get('User-Agent')
  const url = new URL(request.url)
  const page = url.searchParams.get('page')

  return Response.json({
    method: request.method,
    body,
    headers: { userAgent },
    query: { page },
  })
}

Sending Responses

Common response patterns:

src/app/api/responses.ts
export default function handler(request: Request) {
  // JSON
  return Response.json({ message: 'Hello JSON' })

  // Plain text
  // return new Response('Hello Text', {
  //   headers: { 'Content-Type': 'text/plain' },
  // })

  // Custom status
  // return Response.json({ message: 'Created' }, { status: 201 })

  // Redirect
  // return Response.redirect('https://example.com', 302)
}

Dynamic Routes

Path params are available via the x-bini-params header:

src/app/api/posts/[id].ts
export default async function handler(request: Request) {
  const paramsHeader = request.headers.get('x-bini-params')
  let params: Record<string, string> = {}
  try {
    params = paramsHeader ? JSON.parse(paramsHeader) : {}
  } catch {
    return Response.json({ error: 'Invalid params' }, { status: 400 })
  }

  const id = params.id
  if (request.method === 'GET') {
    return Response.json({ id, title: `Post ${id}` })
  }

  return Response.json({ error: 'Method not allowed' }, { status: 405 })
}

Catch-all Routes

[...catch] handles unmatched /api/* 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:

src/app/api/config.ts
import { getEnv, requireEnv } from 'bini-env'

export default function handler(request: Request) {
  const apiKey = requireEnv(request as any, 'MY_API_KEY')
  const debug = getEnv(request as any, 'DEBUG_MODE') ?? 'false'
  const appName = getEnv(request as any, 'APP_NAME') ?? 'Bini.js'

  return Response.json({ appName, debug: debug === 'true', hasKey: !!apiKey })
}
FunctionReturnsBehavior
getEnv(ctx, key)string | undefinedUndefined if missing - use ?? for defaults
requireEnv(ctx, key)stringThrows if missing or empty

Error Handling

Validate input and catch unexpected failures:

src/app/api/safe.ts
import { getEnv } from 'bini-env'

export default async function handler(request: Request) {
  try {
    const body = await request.json()

    if (!body.email) {
      return Response.json({ error: 'Email is required' }, { status: 400 })
    }

    return Response.json({ success: true })
  } catch (error: any) {
    const isDev = getEnv(request as any, 'NODE_ENV') === 'development'
    return Response.json(
      {
        error: 'Internal Server Error',
        ...(isDev && { details: error.message }),
      },
      { status: 500 }
    )
  }
}

When to Use Plain Handlers

ScenarioRecommendation
Single endpoint with simple logicPlain handler
Quick prototypesPlain handler
Simple CRUDPlain handler
Multiple endpoints in one fileUse Hono
Need middlewareUse Hono
Complex routingUse Hono
Large production API surfaceUse Hono
Start with plain handlers. Switch to Hono when you need middleware, nested routes, or larger organization.

Complete Example

A small todos API with validation and method branching:

src/app/api/todos.ts
import { getEnv } from 'bini-env'

const todos: { id: string; title: string; completed: boolean }[] = []

export default async function handler(request: Request) {
  const url = new URL(request.url)
  const id = url.searchParams.get('id')

  try {
    if (request.method === 'GET' && !id) {
      return Response.json(todos)
    }

    if (request.method === 'GET' && id) {
      const todo = todos.find((t) => t.id === id)
      if (!todo) {
        return Response.json({ error: 'Todo not found' }, { status: 404 })
      }
      return Response.json(todo)
    }

    if (request.method === 'POST') {
      const body = await request.json()
      if (!body.title) {
        return Response.json({ error: 'Title is required' }, { status: 400 })
      }
      const todo = {
        id: Date.now().toString(),
        title: body.title,
        completed: false,
      }
      todos.push(todo)
      return Response.json(todo, { status: 201 })
    }

    if (request.method === 'DELETE' && id) {
      const index = todos.findIndex((t) => t.id === id)
      if (index === -1) {
        return Response.json({ error: 'Todo not found' }, { status: 404 })
      }
      todos.splice(index, 1)
      return Response.json({ message: 'Todo deleted' })
    }

    return Response.json({ error: 'Method not allowed' }, { status: 405 })
  } catch (error: any) {
    const isDev = getEnv(request as any, 'NODE_ENV') === 'development'
    return Response.json(
      {
        error: 'Internal Server Error',
        ...(isDev && { details: error.message }),
      },
      { status: 500 }
    )
  }
}
Was this helpful?