Plain Function Handlers

Learn how to create simple API endpoints using plain JavaScript functions in Bini.js.

Plain function handlers are the simplest way to create API routes in Bini.js. They're perfect for simple endpoints that don't need complex routing or middleware.

File-based routing: Your file path determines the API route. A file at src/app/api/hello.ts is served at /api/hello. There are no root / API routes — every file maps to a named route based on its filename.

Basic Handler

Export a default function that receives the Request object. The function name doesn't matter — only the file path determines the route:

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

This creates an endpoint at /api/hello that responds to all HTTP methods.

Route Mapping

Your file structure directly maps to API routes:

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

Check request.method to handle different HTTP verbs:

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

Access different parts of the incoming request:

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

Return different types of responses:

src/app/api/responses.ts
// src/app/api/responses.ts → /api/responses
export default function handler(request: Request) {
  // JSON response
  return Response.json({ message: 'Hello JSON' })
  
  // Plain text response
  return new Response('Hello Text', {
    headers: { 'Content-Type': 'text/plain' }
  })
  
  // Response with custom status
  return Response.json(
    { message: 'Created' }, 
    { status: 201 }
  )
  
  // Redirect response
  return Response.redirect('https://example.com', 302)
}

Dynamic Routes

For dynamic routes, parameters are passed via the x-bini-params header:

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 })
}

Catch-all Routes

Handle all unmatched API routes with [...catch]:

src/app/api/[...catch].ts
// src/app/api/[...catch].ts → /api/*
export default function handler(request: Request) {
  const url = new URL(request.url)
  return Response.json({
    error: 'Not Found',
    path: url.pathname,
    method: request.method,
  }, { status: 404 })
}

Environment Variables

Use getEnv() and requireEnv() — both are auto-imported in API routes:

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

export default function handler(request: Request) {
  const apiKey = requireEnv('MY_API_KEY')
  const debug = getEnv('DEBUG_MODE') ?? 'false'
  const appName = getEnv('APP_NAME') ?? 'Bini.js'
  
  return Response.json({ appName, debug: debug === 'true' })
}
getEnv and requireEnv read from the Hono request context, resolving from the correct source on every platform automatically — Node.js, Bun, Deno, Vercel Edge, Netlify Edge, or Cloudflare Workers.
FunctionReturnsBehavior
getEnv(key)string | undefinedReturns undefined if missing — use ?? for defaults
requireEnv(key)stringThrows immediately if missing or empty

Error Handling

Properly handle errors in your API routes:

src/app/api/safe.ts
// src/app/api/safe.ts → /api/safe
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('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 logic Plain handler
Quick prototypes Plain handler
Simple CRUD operations Plain handler
Multiple endpoints in one file Use Hono
Need middleware Use Hono
Complex routing patterns Use Hono
Production APIs with many routes Use Hono
Start with plain handlers for simple endpoints. Switch to Hono when you need middleware, complex routing, or better organization.

Complete Example

A full-featured plain function handler with validation, error handling, and multiple methods:

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

const todos: any[] = []

export default async function handler(request: Request) {
  const url = new URL(request.url)
  const id = url.searchParams.get('id')
  
  try {
    // GET /api/todos — list all todos
    if (request.method === 'GET' && !id) {
      return Response.json(todos)
    }
    
    // GET /api/todos?id=123 — get single todo
    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)
    }
    
    // POST /api/todos — create a new 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 })
    }
    
    // DELETE /api/todos?id=123 — delete a todo
    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('NODE_ENV') === 'development'
    return Response.json(
      { 
        error: 'Internal Server Error',
        ...(isDev && { details: error.message })
      }, 
      { status: 500 }
    )
  }
}