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.

This guide, in one line

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:

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

`/api` middleware only

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:

OutputRole
dist/Hashed JS/CSS, index.html, optional prerendered HTML from an explicit build target
dist/server/api.jsMiddleware + 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:

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

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

waitUntil for background work

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):

Shell
# 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

Shell
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:

JSON
{
  "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):

RequestHandled by
/api/todosmiddleware → apiRoutes → your route.{js,ts}
/dashboard (guarded page)middleware → redirect or static layer → index.html
/dashboard/__data.jsonmiddleware → your fallback; add loader-registry handling there for live data
/assets/app-*.js, /favicon.icoStatic layer directly (often bypasses middleware)
/, any SPA routemiddleware → 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

JavaScript
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

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 makes env.ASSETS.fetch(request) work in the entry.

  • not_found_handling: "single-page-application" serves dist/index.html for unknown paths.

  • run_worker_first: true runs 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 as context.env.DB in 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:

TEXTpublic/.assetsignore
/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.

Last updated on
Edit this page