Skip to content
Tikab's Toolkit

Server functions

Read this page when you wonder "where is my view / serializer / router?". The answer: they folded into one function.

Anatomy

Loading diagram...

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:

Loading diagram...

Two conventions to internalize:

  1. 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.
  2. Return values must be serializable. Dates become ISO strings in wire types (createdAt: string), and unknown is rejected by the framework's shape check — use any with a justifying comment for free-form JSON (the pattern exists in MailboxEntry.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.