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:

FileRoute
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.

JavaScript
// 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 });
}
  • HEAD is derived from GET automatically; OPTIONS is answered automatically.

  • An unhandled method on a matched path returns 405 with an Allow header.

  • A handler may throw a Response to short-circuit.

  • Params arrive percent-decoded: /api/users/John%20Doe gives params.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.

JavaScript
// 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:

TypeScript
// 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 dev serves your endpoints and rebuilds them on route.* / _middleware.* edits (full-page refresh).

  • otfw serve tries a matching route.* handler first, then SSR — so a request that matches no handler falls through to your pages (pages and endpoints coexist).

  • otfw build emits dist/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.envotfw dev/otfw serve load your .env automatically (Bun), and a deployed dist/server/api.js uses whatever environment the host runtime provides.