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/afw/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/afw/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
Supply a server entry that imports route modules and calls createApiHandler or createFetchHandler. Build that entry as an esdev server target. The compiler/routes plugin alone does not generate an HTTP server or discover API handlers automatically. See Server for a native API entry.
A project prerender/build script can explicitly call emitApiBundle from @opentf/afw-cli/ssg to write dist/server/api.js. This repo's SSG script does so when API routes exist. A plain esdev browser build does not emit it automatically.
Keep server-only imports in route modules. On ES-Runtime, read variables from runtime:process's env; on other runtimes, use the host's environment API or platform bindings supplied as context.env. The retired CLI's automatic Bun .env loading does not apply to this setup.