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