Server functions
Read this page when you wonder "where is my view / serializer / router?". The answer: they folded into one function.
Anatomy
A complete, real file — the admin area's runtime-config functions. Note the gate first in each handler, the Zod schema defining the input type, and the mutation writing an audit row:
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { recordAudit } from "@repo/audit";
import { listConfig, setConfig, type ConfigEntry } from "@repo/config";
import { requireSiteAdmin } from "./access";
export type { ConfigEntry };
/**
* Admin surface over @repo/config — the constance replacement. Site-admin
* gated: runtime settings affect the whole installation.
*/
export const fetchConfigEntries = createServerFn({ method: "GET" }).handler(
async (): Promise<ConfigEntry[]> => {
await requireSiteAdmin();
return listConfig();
},
);
export const saveConfigEntry = createServerFn({ method: "POST" })
.inputValidator(
z.object({
key: z
.string()
.min(1)
.max(100)
.regex(/^[a-z0-9_.-]+$/i, "key: a-z, 0-9, _ . -"),
// The raw text the admin typed. Valid JSON is stored as JSON (numbers,
// booleans, objects); anything else is stored as a plain string.
value: z.string().max(2000),
}).parse,
)
.handler(async ({ data }) => {
const session = await requireSiteAdmin();
let parsed: unknown = data.value;
try {
parsed = JSON.parse(data.value);
} catch {
// Plain string — fine.
}
await setConfig(data.key, parsed);
await recordAudit({
actorUserId: session.user.id,
actorName: session.user.name,
action: "admin.config.set",
entityType: "config",
projectId: null,
summary: `Satte konfignyckeln "${data.key}"`,
meta: { key: data.key },
});
return { ok: true as const };
});What happens on the wire
The client calls saveConfigEntry({ data }) like a local function. Under the hood:
Two conventions to internalize:
- Gates throw
Response.requireSession()throws a 302 to/login,requireSiteAdmin()a 403. The decorator's job, done by ordinary code inside the function. Don't catch these. - Return values must be serializable. Dates become ISO strings in wire types
(
createdAt: string), andunknownis rejected by the framework's shape check — useanywith a justifying comment for free-form JSON (the pattern exists inMailboxEntry.meta).
Thin wrappers over blocks
When the logic already lives in a Toolkit block, the app's server function is just access control plus the boundary. The entire read side of the outbox:
import { createServerFn } from "@tanstack/react-start";
import { requireSession } from "@repo/auth/session";
import { listMailbox, type MailboxEntry } from "@repo/mailer";
export type { MailboxEntry };
/**
* Recent recorded emails. Anyone signed in can see them — this is a
* reviewer/debug surface, not a production inbox. The read side lives in
* @repo/mailer (`listMailbox`); this wrapper only adds the access check and
* the server-function boundary.
*/
export const fetchOutbox = createServerFn({ method: "GET" }).handler(
async (): Promise<MailboxEntry[]> => {
await requireSession();
return listMailbox();
},
);That is the pattern to copy: the block owns the logic, the server function owns who may call it.
The type flow, end to end
What the DRF serializer plus hand-written TS types did in two steps, the type system does in one. Define the wire type once and it travels to the client:
type = {
: string;
: string;
: string;
: boolean;
: string; // ISO — Dates don't serialize
};
declare function (: { : { ?: string } }): <[]>;
const = await ({ : { : "astrid" } });
const = [0];
Change AdminUserRow on the server and client code breaks at compile time — not in
production. That is the whole point.