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.
export function GET(request: Request) { return Response.json({ message: 'Hello!' })}This creates an endpoint at /api/hello that responds to GET requests.
Export named functions for each HTTP method:
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.
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.
Use brackets for dynamic segments. Params are passed as the second argument:
export function GET(request: Request, { params }: { params: { id: string } }) { return Response.json({ id: params.id })}Rest parameters work the same way:
export function GET(request: Request, { params }: { params: { path: string[] } }) { const filePath = params.path.join('/') return Response.json({ path: filePath })}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 })}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 })}export function GET(request: Request) { const auth = request.headers.get('Authorization')
return Response.json({ authenticated: !!auth })}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 })}export function GET(request: Request) { return new Response(JSON.stringify({ data: [] }), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600', }, })}import { redirect } from 'one'
export function GET(request: Request, { params }: { params: { id: string } }) { return redirect(`/api/v2/users/${params.id}`)}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)}API routes use the standard Web Request and Response APIs. TypeScript and Node >= 20 include these globally.
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 })})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.
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.