API Routes
An API route is a route.{js,ts} file — the server analogue of a page's page.{jsx,tsx}. Its folder is the URL, just like pages. Handlers are plain server modules (not components): each exports a function per HTTP method that takes a standard Request and returns a standard Response, so they run unchanged on Bun, Node, Cloudflare Workers, and Deno.
File routing
A route.{js,ts} file works in any folder under app/ (conventionally app/api/); the folder is the URL, using the same [param] / [...rest] convention as pages:
| File | Route |
|---|---|
app/api/status/route.js | /api/status |
app/api/route.js | /api |
app/api/users/[id]/route.js | /api/users/:id |
app/api/files/[...path]/route.js | /api/files/* |
Static routes win over dynamic ones. A folder holds a page.* or a route.*, never both — the toolchain errors on the conflict (as in Next.js's App Router).
Method handlers
Export one function per HTTP method. The second argument is a context with the route params, parsed query, the request url, and a mutable locals bag.
// app/api/users/[id]/route.js export async function GET(request, { params }) { const user = await db.users.find(params.id); return user ? Response.json(user) : Response.json({ error: "Not found" }, { status: 404 }); } export async function POST(request, { params }) { const data = await request.json(); const user = await db.users.update(params.id, data); return Response.json(user, { status: 201 }); }
HEADis derived fromGETautomatically;OPTIONSis answered automatically.An unhandled method on a matched path returns
405with anAllowheader.A handler may throw a
Responseto short-circuit.Params arrive percent-decoded:
/api/users/John%20Doegivesparams.id === "John Doe".
Middleware
_middleware.{js,ts} can live in any folder under app/ — not only app/api/. app/_middleware.js governs every request (pages, loader data, SSR); app/api/_middleware.js scopes to /api/* only. Multiple files compose outermost-first.
// app/api/_middleware.js — scoped to /api/* export default function (request, context, next) { const token = request.headers.get("authorization"); if (!token) return Response.json({ error: "Unauthorized" }, { status: 401 }); context.locals.user = verify(token); return next(); }
Full pipeline, page guards, __data.json scoping, and deploy exports: Middleware.
Request validation (e.g. with zod) belongs in middleware or at the top of a handler.
Cookies use the standards-based helpers from @opentf/web/server — see the Cookies guide (API reference: Server).
TypeScript
Handlers can be authored in .ts. Annotate the exports with the framework types:
// app/api/users/[id]/route.ts import type { ApiHandler, Middleware } from "@opentf/web/server"; export const GET: ApiHandler = (request, { params }) => { return Response.json({ id: params.id }); }; // _middleware.ts default export const auth: Middleware = (request, ctx, next) => next(); export default auth;
Running & deploying
otfw devserves your endpoints and rebuilds them onroute.*/_middleware.*edits (full-page refresh).otfw servetries a matchingroute.*handler first, then SSR — so a request that matches no handler falls through to your pages (pages and endpoints coexist).otfw buildemitsdist/server/api.js, a self-contained handler you deploy with a runtime adapter — see the Server API reference.
Environment variables are never bundled into the client. Handlers read them via process.env — otfw dev/otfw serve load your .env automatically (Bun), and a deployed dist/server/api.js uses whatever environment the host runtime provides.