The one idea
A server function is code you WRITE on the server and
CALL from the client like a local async fn. The compiler wires the RPC — the
input is validated + typed, the return is typed — no
fetch, no JSON, no any.
You write createServerFn({ method: 'POST' }).validator(schema).handler(async ({ data }) => …)
in a server module. You import it in a client component and call it like a normal function.
At build time the Start compiler replaces the server impl with an RPC stub (a fetch to a
generated function ID) in the client bundle — the real body, with its DB/secrets/fs access,
never reaches the browser.
The anatomy — a chained builder
createServerFn() returns a builder. Each chained call returns a new builder with merged
options. Order: declare the method, optionally attach middleware, validate the input, then the handler.
The handler always receives { data } — the already-validated input.
The validator's schema types data; the handler's return type flows back to the caller.
strict: true (the default) makes TypeScript check that both input and output are
serializable across the network boundary.
Side by side — the same "greeting" as a server function and as a hand-rolled endpoint
server/greeting.functions.ts Server Function
import { createServerFn } from '@tanstack/react-start'; import { z } from 'zod'; export const getGreeting = createServerFn({ method: 'POST' }) .validator(z.object({ name: z.string().min(1), })) .handler(async ({ data }) => { // runs ONLY on the server: DB, secrets, file system return { message: `Hello, ${data.name}!`, chars: data.name.length, }; });
// client — call it like a local async fn (typed, no fetch): import { getGreeting } from '#/server/greeting.functions'; const result = await getGreeting({ data: { name: 'Houston' } }); // result.message <-- typed: string // result.chars <-- typed: number
routes/api/echo.ts hand-rolled endpoint
import { createServerFileRoute } from '@tanstack/react-start/server'; export const ServerRoute = createServerFileRoute('/api/echo')({ POST: async ({ request }) => { const body = await request.json(); const name = body.name ?? ''; return new Response(JSON.stringify({ message: `Hello, ${name}!`, chars: name.length, }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); }, });
// client — you fetch, check res.ok, parse JSON, type it yourself: const res = await fetch('/api/echo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Houston' }), }); if (!res.ok) { /* handle status yourself */ } const data = await res.json(); // <-- untyped: any
Try it — call the same input two ways
Type a name (or use a chip) and call it as a Server Function (typed + validated
by a tiny inline Zod-like validator standing in for .validator(z.object({ name: z.string().min(1) })))
or via a hand-rolled endpoint (no validation — echoes anything). The result + the round-trip
flow update live.
The round trip — client call → compiler RPC → server → typed return
The Server Function lane: the client calls the fn like a local async fn; the
compiler has already replaced the body with an RPC stub (a fetch to a generated function ID);
on the server the validator runs first, then the handler; the typed result flows back. The endpoint lane
is all hand-rolled and returns an untyped any.
Comparison — when to reach for which
createServerFn for type-safe, validated,
compiler-RPC'd server functions; createServerFileRoute for raw endpoints (webhooks / non-Start
clients). Cross-refs:
🔗 tanstack_start_overview (the stack; this is the server-fn deep dive) ·
🔗 astro_actions_endpoints (the same "typed server RPC" idea, different framework) ·
ssr_streaming (upcoming — server fns can stream typed data back).