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.

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

`/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 pre-rendered HTML (--ssg)
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 (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:

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

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

Shell
# Example: Cloudflare — Miniflare provides a local D1 at env.DB
wrangler dev
# → http://localhost:8787

2. Proxy matching paths from otfw.config.js:

JavaScript
// otfw.config.js
export default {
  proxy: {
    "/api": "http://localhost:8787",
  },
};

3. Start the dev server as usual:

Shell
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

Shell
otfw build    # → dist/ (client) + dist/server/api.js
# then your platform's deploy command

Wire both steps into package.json when it helps:

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

RequestHandled by
/api/todosmiddlewareapiRoutes → your route.{js,ts}
/dashboard (guarded page)middleware → redirect or static layer → index.html
/dashboard/__data.jsonmiddleware (same scope as /dashboard) → loader 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 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

JavaScript
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

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

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

  • d1_databases[].binding: "DB" surfaces as context.env.DB in 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.