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/afw/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/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.

Last updated on
Edit this page