server functions

[check: …]
📖 Read the full guide — createServerFn, the chained builder API, validators, and the server-function vs hand-rolled-endpoint contrast. This page is the interactive companion.

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.

createServerFn from @tanstack/react-start .validator(schema) — typed + validated input compiler → RPC stub (no manual fetch) hand-rolled fetch: untyped any

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.

Pick a value and press a call button to see the simulated typed result / validation.

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.

Server Fn
1 · clientgetGreeting({ data })
2 · compilerRPC stub: fetch('/_serverFn/<id>')
3 · server · validator.validator(schema) validates input
4 · server · handlerruns ONLY here (DB/secrets)
5 · clienttyped result (inferred)
Endpoint
1 · clientfetch('/api/echo', …)
2 · server · handlernew Response(JSON…)
3 · clientawait res.json()
4 · clientany (untyped)

Comparison — when to reach for which

This bundle is HOW TanStack Start does server calls: 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).