Fetch handler
OTF Web handlers are standard Request → Response functions. They never import a platform SDK, so the same route.*, loader.*, and _middleware.* code runs in otfw dev, in tests, and in production — on any host that exposes the Fetch API: Cloudflare Workers, Deno Deploy, Bun, Vercel Edge, Netlify Edge, and others.
otfw build emits 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/web/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), }), };
That's the whole server. 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 pre-rendered HTML (--ssg) |
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 (otfw build --ssg), 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/web/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 OTF Web 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. Proxy matching paths from otfw.config.js:
// otfw.config.js export default { proxy: { "/api": "http://localhost:8787", }, };
3. Start the dev server as usual:
otfw dev # → http://localhost:3000 (SPA + HMR) # ↪ proxy /api → your backend origin
The browser hits the dev server; the SPA hot-reloads; proxied API requests run on the real platform runtime — same handler code as production. See Configuration → proxy.
Deploy
otfw build # → dist/ (client) + dist/server/api.js # then your platform's deploy command
Wire both steps into package.json when it helps:
{ "scripts": { "deploy": "otfw build && 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 (same scope as /dashboard) → loader 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 an optional dev proxy. 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/web/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" }, "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.d1_databases[].binding: "DB"surfaces ascontext.env.DBin handlers.
Local D1: wrangler d1 execute my-app-db --local --file=./schema.sql. Drop --local (add --remote) for the deployed database.
Dev proxy: proxy: { "/api": "http://localhost:8787" } with wrangler dev on port 8787.
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.