Server Functions

Server functions let a component call server code like an ordinary async function:

  • No hand-written API route, no fetch, no URL.

  • Declared in a *.server.{js,ts} file anywhere under app/.

  • query() for reads, action() for writes, both from @opentf/afw/server.

  • The browser gets a small fetch stub; the server code, its imports and its secrets never reach the bundle.

  • Each function answers under its folder, so that folder's middleware runs first.

Define

JavaScript
// app/todos/todos.server.js — runs only on the server
import { action, query } from "@opentf/afw/server";
import { z } from "zod"; // any Standard Schema library: zod, valibot, arktype…
import { db } from "../../lib/db.js";

export const listTodos = query(async ({ done }, { locals }) => {
  return db.todos.list({ owner: locals.user.id, done });
});

export const addTodo = action(z.object({ title: z.string().min(1) }), async ({ title }, { locals }) => {
  return db.todos.insert({ owner: locals.user.id, title });
});
  • query(fn) / query(schema, fn) — a read. Called with GET (or POST for large arguments); must have no side effects.

  • action(fn) / action(schema, fn) — a write. POST only.

  • fn(input, context) — input is the caller's argument, validated when a schema is given; context is { request, url, locals, env, ctx }.

  • Only exports wrapped in query() / action() are callable. Other exports are server-only helpers: importing one from browser code fails the build.

  • Not allowed in a .server file: export * from, re-exports and destructured exports. The build must see every server function to expose it.

Call from a component

JSX
// app/todos/page.jsx — runs in the browser
import { resource } from "@opentf/afw";
import { addTodo, listTodos } from "./todos.server.js";

export default function Todos() {
  let title = $state("");
  const todos = resource(() => listTodos({ done: false }));

  async function save() {
    await addTodo({ title });
    title = "";
    todos.refetch();
  }

  return (
    <section>
      <ul>{(todos.data ?? []).map((t) => <li>{t.title}</li>)}</ul>
      <input value={title} oninput={(e) => (title = e.target.value)} />
      <button onclick={save}>Add</button>
    </section>
  );
}
  • The import path is the real module, so TypeScript checks each call against the real signature (the schema's input type, the function's return type).

  • listTodos(input, { signal }) — pass an AbortSignal to cancel a call.

  • Works with resource() as-is: it refetches when its arguments change and drops superseded responses.

Refetch after an action

After an action changes data, invalidate() refetches what shows it:

JSX
import { invalidate, resource } from "@opentf/afw";
import { addTodo, listTodos } from "./todos.server.js";

const todos = resource(() => listTodos({ done: false }));

async function save(title) {
  await addTodo({ title });
  await invalidate(listTodos); // every live resource that called listTodos refetches
}
  • invalidate(listTodos, listUsers) — refetch the live resources that called these server functions. Other resources are left alone.

  • invalidate() — refetch every live resource and reload the current page's loader data into router.data. Use it when a page shows loader data, as the starter's todos page does.

  • A resource records the server functions its fetcher calls as it starts (() => listTodos(args)). For one called after an await, name it: resource(fn, { invalidatedBy: [listTodos] }).

  • Resources of unmounted components are not refetched.

  • It resolves when the refetches settle; on the server it does nothing.

Errors

A failed call rejects with a ServerFunctionError (from @opentf/afw):

Server sideStatusOn the error
Input fails the schema400issues — the schema's issues (message, path)
throw new Response(null, { status: 401 })that statusstatus
notFound()404status
A redirect (302, …) from the function or middlewarethat statusredirected: true — not followed
Any other thrown error500a generic message; details are logged on the server only
JavaScript
import { ServerFunctionError } from "@opentf/afw";

try {
  await addTodo({ title: "" });
} catch (error) {
  if (error instanceof ServerFunctionError && error.status === 400) showIssues(error.issues);
  else throw error;
}

Authentication and authorization

Split the work the same way as for pages and API routes:

  • Middleware authenticates — reads the session, sets context.locals.user, and gates whole sections. A guard in app/admin/_middleware.js also guards every server function in app/admin/.

  • Each function authorizes its data — it decides what this user may read or change, from locals:

JavaScript
// app/_middleware.js
export default async function middleware(request, context, next) {
  context.locals.user = await sessionUser(request); // null when signed out
  return next();
}
JavaScript
// app/todos/todos.server.js
import { action, notFound } from "@opentf/afw/server";

export const deleteTodo = action(async ({ id }, { locals }) => {
  if (!locals.user) throw new Response(null, { status: 401 });
  const todo = await db.todos.get(id);
  if (todo?.owner !== locals.user.id) notFound(); // never reveal other users' rows
  await db.todos.delete(id);
});
  • Endpoints are <folder>/__fn/<id>: app/admin/users.server.js answers under /admin/__fn/…. The id is a hash of the file path and export name; it reveals neither.

  • Server functions in the app root answer under /__fn/…, governed by root middleware only.

Call from server code

A server function is also a plain async function on the server: loaders, API routes, server-rendered pages and other server functions call it directly, with no HTTP round trip.

JavaScript
// app/todos/loader.js
import { listTodos } from "./todos.server.js";

export default async function loader({ locals }) {
  return { todos: await listTodos({ done: false }, { locals }) };
}
  • Input is still validated against the schema.

  • No middleware runs for a direct call: pass the locals the caller already has.

Validation

  • Any Standard Schema validator works: zod, valibot, arktype, or a hand-written object with a ~standard.validate method.

  • Validation runs on the server before the function; an invalid call never reaches it.

  • Without a schema, the function receives the decoded input as it is — validate it yourself before trusting it.

What can be sent

Arguments and results use @opentf/serialize:

  • Plain JSON data, plus Date, Map, Set, BigInt, URL, RegExp, Error, typed arrays, undefined, NaN / ±Infinity / -0, Temporal values, and shared or cyclic references.

  • Plain data stays readable in the network panel: {"v": {"title": "x"}}.

  • Not supported: functions, symbols and class instances. Returning one is a 500.

Security defaults

  • Only query() / action() exports are reachable.

  • Cross-site calls are refused (403): a request must be same-origin by its Origin or Sec-Fetch-Site header. Non-browser clients without either header are allowed.

  • POST bodies must be application/json (415 otherwise), so a plain HTML form on another site cannot submit one.

  • Request input is decoded within limits: 1 MiB (413 beyond it) and 64 levels of nesting (400). A __proto__ key stays an ordinary property.

  • Responses are Cache-Control: no-store.

  • Errors never send server details to the browser.

Setup

Vite + Node — nothing to configure:

  • vite dev answers server functions inside the dev server; editing a .server file applies without a restart.

  • vite build ships a Node server (dist/server/index.js) whenever the app has server functions.

esdev + ES-Runtime — the server target imports the composed handler from the generated @afw/server module:

JavaScript
// server.js — the server target's entry
import { serve } from "runtime:http";
import { file } from "runtime:fs";
import { env } from "runtime:process";
import { createHandler, createPageRenderer } from "@afw/server";

// Beside this bundle: dist/ in production, .dev/dist/ under `esdev start`.
const outDir = new URL("./", import.meta.url);

async function files(request) {
  const { pathname } = new URL(request.url);
  if (pathname === "/" || pathname.endsWith(".html")) return null; // pages render below
  const asset = file(new URL(`.${pathname}`, outDir));
  if (!(await asset.exists())) return null;
  const type = { js: "text/javascript; charset=utf-8", css: "text/css; charset=utf-8" }[pathname.split(".").pop()];
  return new Response(asset.stream(), { headers: { "content-type": type ?? "application/octet-stream" } });
}

const shell = await file(new URL("./index.html", outDir)).text();
const handle = createHandler({
  files,
  renderPage: createPageRenderer({ shell }), // with mode: "ssr"; leave out for an SPA
  fallback: () => new Response("Not Found", { status: 404 }),
});
serve({ port: Number(env.PORT ?? "3000") }, handle);
JSONesdev.json
{
  "plugins": [{ "module": "@opentf/esdev-plugin-afw", "options": { "mode": "ssr" } }],
  "build": { "targets": {
    "web": { "entry": "index.html", "outdir": "dist" },
    "server": { "entry": "server.js", "out": "dist/server.js" }
  } },
  "dev": { "run": "server", "app": { "port": 3000 } }
}
  • createHandler runs the app's middleware around API routes, server functions, loader data, files and renderPage, in that order.

  • createPageRenderer exists with mode: "ssr": it registers every page and renders them per request into the shell, with loader data.

  • esdev start runs the server target (dev.run) and restarts it when server code changes; an edited .server file applies without a page reload.

  • esdev build, then esdev dist/server.js, runs it in production.

  • Browser targets get the stubs automatically.

A static site cannot call server functions

A build with no server (server: false, or a static host) has nothing to answer the call, so importing a server function there fails the build.

Not yet

  • Forms that submit to an action without JavaScript.

  • Single-flight updates: an action returning the fresh query data with its response, saving the refetch round trip.

  • Server functions declared inside a component file.

  • Streaming results.

Last updated on
Edit this page