One Logo Pool Ball
wwwwwwwwwwwwwwwwwww

API Routes

API routes let you create HTTP endpoints in your One app. Add +api.ts to the end of your filename and export handler functions for each HTTP method.

Basic Usage

app/api/hello+api.ts
export function GET(request: Request) {
return Response.json({ message: 'Hello!' })
}

This creates an endpoint at /api/hello that responds to GET requests.

HTTP Methods

Export named functions for each HTTP method:

app/api/users+api.ts
export function GET(request: Request) {
return Response.json({ users: [] })
}
export function POST(request: Request) {
// handle POST
}
export function PUT(request: Request) {
// handle PUT
}
export function DELETE(request: Request) {
// handle DELETE
}
export function PATCH(request: Request) {
// handle PATCH
}
export function HEAD(request: Request) {
// set headers for a GET without returning a body
return new Response(null, { headers: { 'x-total-count': '42' } })
}
export function OPTIONS(request: Request) {
// shape CORS preflight responses — see the parity note below
return new Response(null, {
status: 204,
headers: {
'access-control-allow-origin': request.headers.get('origin') || '*',
'access-control-allow-methods': 'GET, POST, OPTIONS',
},
})
}

You can also export a default function as a catch-all:

export default function handler(request: Request) {
return Response.json({ method: request.method })
}

If a route has no handler for the requested method and no default export, One returns 405 Method Not Allowed with an Allow header listing its exported HTTP method handlers. API routes require explicit HEAD and OPTIONS handlers, or a default handler; middleware can also answer these requests.

Dev and prod parity

API route handlers — including HEAD and OPTIONS — are dispatched the same way in both one dev (Vite) and one serve (Hono on Node / Cloudflare Workers / Vercel). Your middleware chain and method exports are the source of truth: whatever response they produce in dev is what ships in prod.

For OPTIONS preflight specifically, One configures Vite’s dev server with server.cors: { preflightContinue: true, origin: true, credentials: true } so the built-in cors middleware sets permissive headers on non-preflight requests but lets preflight OPTIONS fall through to your handlers. If you override server.cors in your own vite.config.ts, keep preflightContinue: true or your middleware’s preflight response won’t reach the browser in dev.

Dynamic Routes

Use brackets for dynamic segments. Params are passed as the second argument:

app/api/users/[id]+api.ts
export function GET(request: Request, { params }: { params: { id: string } }) {
return Response.json({ id: params.id })
}

Rest parameters work the same way:

app/api/files/[...path]+api.ts
export function GET(request: Request, { params }: { params: { path: string[] } }) {
const filePath = params.path.join('/')
return Response.json({ path: filePath })
}

Request Handling

Reading the Body

export async function POST(request: Request) {
// JSON
const json = await request.json()
// Form data
const form = await request.formData()
// Raw text
const text = await request.text()
return Response.json({ received: true })
}

Query Parameters

export function GET(request: Request) {
const url = new URL(request.url)
const page = url.searchParams.get('page') || '1'
const limit = url.searchParams.get('limit') || '10'
return Response.json({ page, limit })
}

Headers

export function GET(request: Request) {
const auth = request.headers.get('Authorization')
return Response.json({ authenticated: !!auth })
}

Response Handling

Custom Status Codes

export async function POST(request: Request) {
const data = await request.json()
if (!data.name) {
return Response.json({ error: 'Name required' }, { status: 400 })
}
return Response.json({ id: 1, ...data }, { status: 201 })
}

Custom Headers

export function GET(request: Request) {
return new Response(JSON.stringify({ data: [] }), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600',
},
})
}

Redirects

import { redirect } from 'one'
export function GET(request: Request, { params }: { params: { id: string } }) {
return redirect(`/api/v2/users/${params.id}`)
}

Error Handling

export async function GET(request: Request, { params }: { params: { id: string } }) {
const user = await getUser(params.id)
if (!user) {
return Response.json({ error: 'User not found' }, { status: 404 })
}
return Response.json(user)
}

Web Standards

API routes use the standard Web Request and Response APIs. TypeScript and Node >= 20 include these globally.

Type Helpers

One exports an Endpoint type for simple cases:

import type { Endpoint } from 'one'
export const GET: Endpoint = (request) => {
return Response.json({ hello: 'world' })
}

For typed params, use createAPIRoute:

import { createAPIRoute } from 'one'
export const GET = createAPIRoute<'/api/users/[id]'>((request, { params }) => {
// params.id is typed as string
return Response.json({ id: params.id })
})

Cloudflare Worker Runtime

When deployed to Cloudflare Workers, API handlers also receive a worker context containing the worker’s env bindings and executionCtx (with waitUntil and passThroughOnException). The worker key is undefined on Node/dev so you can write runtime-portable code.

app/api/track+api.ts
import { createAPIRoute } from 'one'
export const POST = createAPIRoute(async (request, { worker }) => {
const body = await request.json()
// fire-and-forget: keep the worker alive for the background task
// without blocking the response
worker?.executionCtx?.waitUntil(
fetch('https://analytics.example.com/track', {
method: 'POST',
body: JSON.stringify(body),
})
)
return Response.json({ ok: true })
})

The worker context has this shape:

type WorkerContext = {
env?: Record<string, unknown> // your wrangler bindings (KV, D1, R2, etc.)
executionCtx?: {
waitUntil: (p: Promise<unknown>) => void
passThroughOnException: () => void
}
}

Use env to access bindings you’ve configured in wrangler.json (databases, KV namespaces, secrets, the ASSETS static-asset binding), and executionCtx.waitUntil() for background work that should outlive the response.

Edit this page on GitHub.