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