Fetch handler
AFW handlers are standard Request → Response functions. They never import a platform SDK, so the same route.*, loader.*, and _middleware.* code runs in an app-owned development server, in tests, and in production — on any host that exposes the Fetch API: Cloudflare Workers, Deno Deploy, Bun, Vercel Edge, Netlify Edge, and others.
An explicit build script can call emitApiBundle from @opentf/afw-cli/ssg to emit a self-contained dist/server/api.js. Pair it with the static dist/ output and a small entry file on your platform: wire createFetchHandler to the runtime's fetch, serve dist/ through the host's static layer, and read databases or secrets from context.env when the platform provides them.
In dev, run your API on the platform's local runtime and proxy /api/* (or the whole backend) to it. In production, ship one Fetch handler: pipeline middleware + apiRoutes for API and guarded pages, and a fallback for static files and the SPA shell.
The production entry
dist/server/api.js exports middleware, apiRoutes, and apiHandler. For a full-stack app — middleware on pages, loader data, and API routes — pair apiRoutes with the pipeline middleware runner and fall back to your static layer:
// server.js — adapt the filename to your platform import { apiRoutes, middleware } from "./dist/server/api.js"; import { createFetchHandler } from "@opentf/afw/server"; export default { fetch: createFetchHandler(apiRoutes, { middleware, // Unmatched requests are static assets or SPA routes — delegate to your host. fallback: (request, env) => env.ASSETS.fetch(request), }), };
This entry handles APIs and delegates other requests to static assets. It does not run route loaders or SSR. Add those to the fallback when your app needs them. createFetchHandler threads the runtime's env and ctx through middleware, handlers, and the fallback, so platform bindings are available everywhere you need them.
apiHandler composes app/api/_middleware.js only — use it when you have no app/_middleware.js outside app/api/. Do not pass pipeline middleware as well or the chain runs twice. See Server.
Static assets and SPA fallback
Full-stack deployments usually split two outputs from one build:
| Output | Role |
|---|---|
dist/ | Hashed JS/CSS, index.html, optional prerendered HTML from an explicit build target |
dist/server/api.js | Middleware + API dispatch |
Your platform serves dist/ from its CDN or assets binding. Configure SPA fallback so unknown paths return index.html for client-side routes. For a fully pre-rendered site (a configured prerender target), every route already has its own HTML — use a 404 page or direct file serving instead.
The exact config is platform-specific (Wrangler assets, Deno Deploy static sites, etc.). The framework only needs your fallback to forward unmatched requests to that layer.
Platform bindings in handlers
On runtimes that pass bindings through fetch, they arrive on context.env — no import, no setup:
// app/api/todos/route.js export async function GET(request, { env }) { const { results } = await env.DB.prepare("SELECT id, title FROM todos").all(); return Response.json(results); } export async function POST(request, { env }) { const { title } = await request.json(); await env.DB.prepare("INSERT INTO todos (title) VALUES (?)").bind(title).run(); return Response.json({ ok: true }, { status: 201 }); }
Authoring in TypeScript? Type the bindings once and cast env:
import type { ApiHandler } from "@opentf/afw/server"; interface Env { DB: D1Database } export const GET: ApiHandler = async (request, { env }) => { const { results } = await (env as Env).DB.prepare("SELECT * FROM todos").all(); return Response.json(results); };
Use context.env for platform bindings (D1, KV, secrets) and process.env for plain environment variables where the runtime provides them.
Need to do work after the response is sent (logging, cache warming)? On Workers-style runtimes it's on context.ctx: ctx.waitUntil(logAsync()).
Development
Platform bindings often exist only inside the host's dev runtime, so a common pattern is two processes: the AFW dev server for the SPA, and the platform's local server for the API.
1. Run your API on the platform's dev command (e.g. wrangler dev, deno task dev):
# Example: Cloudflare — Miniflare provides a local D1 at env.DB wrangler dev # → http://localhost:8787
2. Configure forwarding in your app server or an external proxy. The retired CLI's afw.config.js proxy setting is not consumed by esdev. Your server entry owns the browser endpoint, asset serving and backend forwarding in fullstack mode. See Server.
3. Build/watch the configured targets with esdev. Use a native server target and configure esdev's fullstack development loop for that entry. See the official development guide.
Deploy
esdev build --minify # configured client/server targets and explicit build steps # then your platform's deploy command
Wire both steps into package.json when it helps:
{ "scripts": { "deploy": "esdev build --minify && wrangler deploy" } }
Replace wrangler deploy with whatever your host expects.
How a request resolves
Same order in dev (via the proxy) and production (in your Fetch handler):
| Request | Handled by |
|---|---|
/api/todos | middleware → apiRoutes → your route.{js,ts} |
/dashboard (guarded page) | middleware → redirect or static layer → index.html |
/dashboard/__data.json | middleware → your fallback; add loader-registry handling there for live data |
/assets/app-*.js, /favicon.ico | Static layer directly (often bypasses middleware) |
/, any SPA route | middleware → static layer → index.html (SPA fallback) |
Platform examples
Nothing above is baked into the framework — it's your entry file, your host config, and any app-owned development forwarding. Node uses the node:http adapter instead; see Server.
Cloudflare Workers
A single Worker serves static dist/ through the assets binding and runs API routes in the same fetch handler.
worker.js
import { apiRoutes, middleware } from "./dist/server/api.js"; import { createFetchHandler } from "@opentf/afw/server"; export default { fetch: createFetchHandler(apiRoutes, { middleware, fallback: (request, env) => env.ASSETS.fetch(request), }), };
wrangler.jsonc
{ "name": "my-app", "main": "worker.js", "compatibility_date": "2026-04-22", "compatibility_flags": ["nodejs_compat"], "assets": { "directory": "dist", "binding": "ASSETS", "not_found_handling": "single-page-application", "run_worker_first": true }, "d1_databases": [ { "binding": "DB", "database_name": "my-app-db", "database_id": "<your-id>" } ] }
assets.binding: "ASSETS"is what makesenv.ASSETS.fetch(request)work in the entry.not_found_handling: "single-page-application"servesdist/index.htmlfor unknown paths.run_worker_first: trueruns the handler before assets, so middleware can guard HTML and data files. Without it, matching assets can bypass your handler.d1_databases[].binding: "DB"surfaces ascontext.env.DBin handlers.
Keep server bundles out of public assets. For this layout, copy a public/.assetsignore file into dist/ through the browser target's assets: ["public"] setting:
/server/
That excludes dist/server/api.js and loader bundles from asset uploads; Wrangler still bundles imports from the Worker entry. Prefer a separate server output directory when possible. See Cloudflare's official asset configuration.
Local D1: wrangler d1 execute my-app-db --local --file=./schema.sql. Drop --local (add --remote) for the deployed database.
Development forwarding: run wrangler dev on port 8787 and configure forwarding in your app-owned server or an external proxy. Adding a legacy proxy block to afw.config.js does not configure esdev.
Other Fetch-native hosts
The same createFetchHandler entry works on Bun and Deno when you export fetch. Map your static dist/ through the host's asset serving or a fallback that reads from disk. Bindings and env access follow each platform's docs — handlers stay unchanged.