Server

A static dist/ covers SPA and pre-rendered sites. When your app renders per request (SSR) or exposes API routes, you run a server instead. There are two ways to ship it.

Option 1 — otfw serve (Bun)

The simplest path. otfw serve builds the client, then runs a Bun server that renders each route on request and mounts your route.{js,ts} endpoints at their paths:

Shell
bun run serve
# → otfw serve   (add --port 8080 to override)

One process serves SSR and API routes, plus the static assets from dist/. For pages with a sibling loader.{js,ts}, the loader runs on each request before the page renders and its result is available as router.data — same wire format as SSG and SPA navigation. See Data Fetching.

This is the recommended target when you control the runtime (a VM, container, or any Bun host).

How a request resolves

Middleware runs first on pages, loader data, API routes, and SSR alike. Then a matching API handler, then <path>/__data.json, then static assets from dist/, and everything else falls through to SSR. Unmatched paths render your 404.

Option 2 — deploy dist/server/api.js with an adapter

otfw build emits a self-contained dist/server/api.js exporting middleware, apiRoutes, and apiHandler (the runtime dispatcher is bundled in; your npm deps stay external). Pair it with the static dist/ — served by the platform's CDN / static layer — to run your server on any runtime.

Fetch-native runtimes (Cloudflare Workers, Bun, Deno) use createFetchHandler with the pipeline middleware runner and API dispatch. The runtime's fetch arguments (env, ctx) are threaded into middleware and handlers as context.env / context.ctx — so on Workers a handler reads bindings via env.DB, and a fallback can serve static assets from the platform's assets binding:

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), // static assets / SPA
  }),
};

For a full worked example — Fetch entry, static assets, platform bindings, and the dev proxy — see Fetch handler.

Node uses the node:http adapter:

JavaScript
import { createServer } from "node:http";
import { apiRoutes, middleware } from "./dist/server/api.js";
import { createFetchHandler, toNodeListener } from "@opentf/web/server";

const fetch = createFetchHandler(apiRoutes, { middleware });
createServer(toNodeListener(fetch)).listen(3000);

See the Server API reference for the full adapter surface.

Pair with static output

Build the client with otfw build (or --ssg for pre-rendered HTML) and let your host serve dist/ from its CDN, while the adapter above runs middleware and API dispatch. This split — static edge + server handler — deploys cleanly to Workers, serverless, and Node hosts alike.

Route loaders

Server-side data for pages ships today as route loaders — a loader.{js,ts} file next to page.* that never bundles to the client. Under otfw serve it runs per request; under otfw build --ssg it runs at build time and writes inline data plus <path>/__data.json for static hosts.

JavaScript
// app/todos/loader.js — server-only; safe to import DB clients
export default async function loader({ params, query, request, locale }) {
  return { items: await db.todos.list() };
}
JSX
// app/todos/page.jsx
import { router } from "@opentf/web";

export default function Todos() {
  return (
    <ul>
      {(router.data?.items ?? []).map((t) => (
        <li>{t.title}</li>
      ))}
    </ul>
  );
}

Loaders share the same router.data surface across SSR, SSG, and client navigation. For interaction-driven or per-user fetches after mount, use client-side resource() or call your API routes with fetch.

Full guide: Data Fetching.

Environment variables

Environment variables are never bundled into the client, so secrets can't leak to the browser. Loaders and API handlers read them via process.env — otfw serve loads your .env automatically (Bun), and a deployed dist/server/api.js uses whatever environment the host runtime provides.

On runtimes that pass bindings through fetch (Cloudflare Workers), those arrive on context.env instead — env.DB for D1, KV namespaces, secrets — and context.ctx exposes waitUntil for background work. Use context.env for platform bindings and process.env for plain variables. See Fetch handler.

What ships today

AreaStatus
Per-request SSR (otfw serve)✅
Route loaders → router.data✅
First-paint hydration✅ — Hydration
Middleware (_middleware.js)✅
Cookie helpers (@opentf/web/server)✅ — Cookies
File-based API routes✅
Client resource() primitive✅
Compiler co-located export loader / queries / actions🔜 roadmap

Until Phase B lands, keep server-only imports in loader.*, route.*, and _middleware.* files — not in components. Components reach the server through router.data (loader output), resource(), or fetch to API routes.

Full middleware guide: Middleware.