The request lifecycle
How TanStack Start works is the prerequisite for this page — read it
first. It introduces the four ideas (file routes, server functions, loaders, SSR) at a
glance. This page goes one level down: what actually happens when the browser hits a
URL or a component calls a server function — the compile-time split that makes
createServerFn an RPC, the validator pipeline, how loaders feed a render, and where SSR
hands off to hydration. It's grounded in the real files in example/; every snippet is
copied from one, with the path on the block.
The whole model in one sentence: you write typed functions, and the bundler decides which half of each one runs where. Everything below is the consequence of that.
The two worlds
Start runs your code in two places, from one source tree:
- The server (Nitro) — handles the HTTP request, runs server functions, talks to Postgres, and renders React to an HTML string.
- The browser — receives that HTML, hydrates it into a live React tree, and from then on calls back to the server for data and mutations.
A handful of files straddle both worlds (your route components render on the server and
the client). The rest are firmly on one side — and the bundler enforces it. A
*-server.ts file's body never ships to the browser; a component that touches
window must never run on the server. Most lifecycle bugs are a violation of that line,
so the sections below keep pointing back to it.
createServerFn is the RPC boundary
A server function is authored as one ordinary, fully-typed async function. But only
the .handler() body is server code. At build time the bundler produces two artifacts
from that single definition:
- On the server: the real handler — it runs the validator, opens the DB, reads the session, returns a value.
- In the browser bundle: a tiny stub with the same signature. Calling it issues an HTTP request to the server, where the real handler runs, and resolves with the deserialized result. The handler's source — and everything it imports — is stripped out of the client bundle.
Here is the file the session reader lives in — fetchSession, which every guarded page
leans on (alongside a second tiny read, getEdgeSsoEnabled):
import { createServerFn } from "@tanstack/react-start";
import { loadSsoConfigFromEnv } from "@tikab-interactive/fusion-edge-sso";
import { getSession } from "#/lib/auth";
import { db } from "#/lib/db";
/**
* The current session for the UI, or null. Safe to call from `beforeLoad`
* guards and route loaders — runs server-side via createServerFn, so the
* server-only auth module never reaches the client bundle.
*
* `isSiteAdmin` lives on the user row (not in the Better Auth session), so we
* read it here for nav + route gating of the admin area.
*/
export const fetchSession = createServerFn({ method: "GET" }).handler(async () => {
const session = await getSession();
if (!session) return null;
const row = await db.query.user.findFirst({
where: (u, { eq }) => eq(u.id, session.user.id),
columns: { isSiteAdmin: true },
});
return {
user: {
id: session.user.id,
name: session.user.name,
email: session.user.email,
isSiteAdmin: row?.isSiteAdmin ?? false,
},
};
});
/**
* Whether edge SSO is switched on (drives the "Sign in via SSO" affordance on the
* login page). Reads the same `SSO_*` env fusion-edge-sso uses; the resolver itself
* is pure/isomorphic so this stays safe to import from the login route.
*/
export const getEdgeSsoEnabled = createServerFn({ method: "GET" }).handler(() => {
return loadSsoConfigFromEnv(process.env).enabled;
});getSession and db are imported at the top of that file. Both are server-only:
getSession pulls in Better Auth and node: modules; db opens a Postgres pool. Neither
ever appears in the browser bundle, because the bundler sees they're only reachable from
inside a .handler() and tree-shakes the whole import chain out of the client artifact.
This is the security boundary — not a convention, a compile-time fact. The browser
literally does not contain the code that reads your database or your secrets; it contains a
stub that says "POST to the server and wait."
The file's own doc comment states the contract plainly — route components import only the
createServerFn handles, and the db calls compile out of the browser bundle:
/**
* Persistence for Carola's "Allmänt" (general) threads — the returnable, ChatGPT-style
* history. Every read/write is OWNER-SCOPED to the signed-in user, so one user can never
* load or overwrite another's threads. Route components import only the `createServerFn`
* handles; the db calls are compiled out of the browser bundle.
*/Why there is no api/ folder for your data
example/src/lib/ is entirely server functions — every *-server.ts. There is no
parallel src/api/ directory full of controllers, because there are no controllers to
write. The endpoint that a server function POSTs to is generated; you never name it,
register it, or parse its request. (There is a small src/routes/api/ — but that's only
for the few cases that genuinely need a raw HTTP handler, covered below.)
The call path, end to end
When a component (or a loader) calls a server function, this is the round trip:
Three checkpoints sit between the call and the database — method, validator, session/owner-scope — and they all run on the server, in that order, before a single row is touched. The client can't skip any of them, because the client only has the stub.
The validator pipeline
.inputValidator(fn) registers a function that runs server-side, before the handler,
on the incoming data. Its return value becomes the handler's typed data; if it throws,
the request fails before the handler ever runs. The example passes Zod's .parse straight
in — the schema is the validator:
/** Rename a thread (locks `title_edited` so saves stop re-deriving it from the first message). */
export const updateCarolaThreadTitle = createServerFn({ method: "POST" })
.inputValidator(z.object({ id: z.string().min(1), title: z.string().min(1).max(160) }).parse)
.handler(async ({ data }) => {
const session = await requireSession();
await db
.update(carolaThread)
.set({ title: data.title.trim().slice(0, 160), titleEdited: true, updatedAt: new Date() })
.where(and(eq(carolaThread.id, data.id), eq(carolaThread.ownerId, session.user.id)));
await reindexEntity("conversation", data.id).catch(() => {});
return { ok: true };
});Two layers of defense are visible here, and they're deliberately separate:
- The validator guarantees
datais well-formed —idnon-empty,title1–160 chars. Shape only. - The handler guarantees the caller is allowed —
requireSession()(401 if not signed in) and theeq(carolaThread.ownerId, session.user.id)clause, so theUPDATEonly ever matches this user's row. A valid payload with someone else's threadidchanges nothing.
A validator is about input integrity; ownership and permission live in the handler. The
Authorization page is the deep dive on that second layer (the
list-then-recheck pattern, the shared permission registry). The Data page covers
the Drizzle calls themselves and why every query carries an ownerId filter.
Method, GET vs POST
createServerFn({ method }) picks the HTTP verb of the generated endpoint. The convention
in the example is the obvious one — GET for reads, POST for writes — and it lines
up with the CSRF middleware (next section), which only guards state-changing verbs. Reads
like loadCarolaThread, listCarolaThreads, getFileContent are GET; every mutation
(save…, update…, delete…, toggle…) is POST.
FormData and file uploads
The validator receives whatever the client sent — including a FormData for a multipart
upload. The validator's job there is to assert the kind, then the handler pulls fields
off it:
export const uploadChatDocument = createServerFn({ method: "POST" })
.inputValidator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected multipart FormData");
return data;
})
.handler(async ({ data }) => {
const session = await requireSession();
const file = data.get("file");
const scope =
String(data.get("scope") ?? "general").trim() === "project" ? "project" : "general";
const scopeKey = scope === "project" ? String(data.get("scopeKey") ?? "").trim() || null : null;
const threadId = String(data.get("threadId") ?? "").trim() || null;
if (!(file instanceof File)) throw new Error("No file provided");
if (file.size > MAX_BYTES) throw new Error("File too large (max 50 MB)");
if (scope === "project" && !scopeKey) throw new Error("project scope requires a scopeKey");
const key = uploadKey("chat-docs", extForName(file.name));
const buffer = Buffer.from(await file.arrayBuffer());
await putObject(key, buffer, file.type || "application/octet-stream");
const id = generateShortId();
await db.insert(chatDocument).values({
id,
ownerId: session.user.id,
scope,
scopeKey,
filename: file.name,
storageKey: key,
mimeType: file.type || "",
sizeBytes: file.size,
status: "pending",
});
// Attach to the conversation it was uploaded into — it rides this chat across messages.
if (threadId) {
await db
.insert(chatDocumentAttachment)
.values({ documentId: id, threadId })
.onConflictDoNothing();
}
await kickProcessing(id);
const [latest] = await db
.select({ status: chatDocument.status })
.from(chatDocument)
.where(eq(chatDocument.id, id))
.limit(1);
return { id, status: latest?.status ?? "pending", filename: file.name };
});createServerFn detects the FormData and POSTs it as multipart/form-data instead of
JSON; the handler reads the bytes server-side, streams them to object storage
(Storage), inserts the owner-scoped chat_document row, attaches it to the
thread, then kicks off processing. Same boundary, same ownership check — only the body
encoding changes.
Calling a server function
You call the stub like any async function — no URL, no fetch, no response parsing. Two
places do it:
From a loader (the data path — see the next section). Note the call convention: inputs
go under a data key.
loader: async ({ params }) => ({
overview: await getProjectOverview({ data: { key: params.projectKey } }),
}),From an event handler (the mutation path). When a write changes data the UI is
showing, call the server function, then refresh — either router.invalidate() to re-run
the loaders, or a local refetch. The real conversation header wires every action this way:
return (
<ConversationHeader
title={title}
starred={starred}
projects={myProjects}
currentProjectKey={currentProjectKey}
filesCount={filesCount}
onOpenFiles={onOpenFiles}
onPopOut={onPopOut}
portalTarget={portalTarget}
labels={headerLabels()}
onRename={(t) =>
void updateCarolaThreadTitle({ data: { id: threadId, title: t } })
.then(refreshThreads)
.catch(() => {})
}
onToggleStar={() =>
void toggleCarolaThreadStar({ data: { id: threadId, starred: !starred } })
.then(refreshThreads)
.catch(() => {})
}
onAddToProject={(key, name) =>
void updateCarolaThreadScope({
data: { id: threadId, scope: "project", scopeKey: key, scopeLabel: name },
})
.then(refreshThreads)
.then(() =>
router.navigate({
to: "/project/$projectKey/c/$threadId",
params: { projectKey: key, threadId },
}),
)
.catch(() => {})
}
onDelete={() =>
void deleteCarolaThread({ data: { id: threadId } })
.then(refreshThreads)
.then(() => closeConversation())
.catch(() => {})
}
/>
);This is the deliberate alternative to useEffect(() => fetch(...)). The component doesn't
know an endpoint exists — it imported updateCarolaThreadTitle from a *-server.ts file
and awaited it. The types of data and the return value are checked at compile time,
end to end.
Loaders: data before render
A route's loader runs on the server during the initial request, and again on the client when you navigate to the route. It resolves before the component renders, and the component reads the result synchronously — there's no loading-state dance:
import { createFileRoute } from "@tanstack/react-router";
import { ProjectOverview } from "#/components/project/ProjectOverview";
import { getProjectOverview } from "#/lib/project-overview-server";
/**
* Project overview — the landing inside a building (`/project/$projectKey`). The
* per-building "what needs you / what's happening", loaded server-side (SSR, no flash).
*/
export const Route = createFileRoute("/_authed/project/$projectKey/")({
loader: async ({ params }) => ({
overview: await getProjectOverview({ data: { key: params.projectKey } }),
}),
component: ProjectIndex,
});
function ProjectIndex() {
const { overview } = Route.useLoaderData();
return <ProjectOverview overview={overview} />;
}On the first visit, the loader runs on the server, its data is rendered into the HTML
(and serialized alongside it so the client doesn't refetch on hydration). On client
navigation to the same route, the loader runs in the browser — which just calls the same
getProjectOverview stub, i.e. one more RPC. Either way Route.useLoaderData() hands the
component fully-typed, already-loaded data.
beforeLoad is the loader's sibling that runs first and can redirect. The _authed
layout's Route uses it as the auth gate, and stashes the resolved user on the route
context so every nested page reads it without re-fetching (the same Route also has a
loader that fetches the viewer's projects, and component: AuthedLayout):
beforeLoad: async () => {
const session = await fetchSession();
if (!session) throw redirect({ to: "/login" });
return { user: session.user };
},Because _authed.tsx is a pathless layout, every
signed-in page nests inside it — so "redirect to /login when there's no session" and
"the user is on Route.useRouteContext().user" are true everywhere, written once. The
throw redirect(...) short-circuits the whole lifecycle: no loader, no render, just a
302/redirect.
SSR → hydration
Once beforeLoad and the loaders have resolved, the server renders the matched route
components to an HTML string and sends it — with the serialized loader data inline. The
browser paints that HTML immediately (real content, good for perceived speed, SEO, and
no-JS resilience), then hydrates: React walks the existing DOM and attaches event
handlers instead of recreating it. The document shell that wraps every page is
__root.tsx:
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang={getLocale()} {...mantineHtmlProps}>
<head>
<ColorSchemeScript />
<HeadContent />
</head>
<body>
<FusionProvider theme={appTheme} cssVariablesResolver={appCssVariablesResolver}>
{children}
</FusionProvider>
<HydrationMarker />
<Scripts />
</body>
</html>
);
}Wrapping the render is request middleware in src/start.ts — it runs on the server for
every request, before the route lifecycle:
export const startInstance = createStart(() => ({
// localeMiddleware first so its locale context wraps everything downstream,
// the render included.
requestMiddleware: [localeMiddleware, csrfMiddleware],
}));csrfMiddlewarerejects state-changing verbs (POST/PUT/…) that fail a same-origin check — this is what makes thePOSTserver functions safe. Safe verbs (GET/HEAD) stay open so navigations work.localeMiddlewareruns the rest of the request inside Paraglide'sAsyncLocalStoragecontext, sogetLocale()returns the right locale during the SSR render — no server/client mismatch (i18n).
The gotchas hydration brings
Because the same component code runs on the server and the client, anything that only exists in a browser breaks the server render. These are the real ones in this codebase:
Client-only components (the mounted-flag gate). Some libraries reach for window,
document, or a second React runtime and simply cannot render on the server. fusion-ui's
<Chat> (built on @tanstack/ai-react's useChat) is the canonical case — SSR'ing it
throws a duplicate-React useId(): null. The fix is to render it only after the client has
mounted, behind a flag:
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);useEffect never fires on the server, so mounted is false through the SSR pass and the
first hydration render (the markup matches), then flips true and the client-only subtree
appears. The same shape covers MapLibre (GIS) and the collab/realtime widgets —
the Sandbox page lists them. A crashed SSR dev server from one of
these needs a full restart, not just a refresh.
The hydration marker. __root.tsx sets data-hydrated="true" in a useEffect. It's
not cosmetic: the e2e suite waits on it so a click can't land on pre-hydration markup and
trigger a native form submit that skips the React handlers.
function HydrationMarker() {
useEffect(() => {
document.documentElement.dataset.hydrated = "true";
}, []);
return null;
}SSR drops fire-and-forget work. When the server finishes rendering, the response is
sent and the request scope ends — any promise you didn't await and any setTimeout you
scheduled may never run. So don't kick background work off the render thread with a
bare promise or a timer. Hand it to a job (or do it inline on the long-running
server, but never as a detached promise during SSR). The upload handler shows the right
shape — a Hatchet workflow when available, otherwise inline, but always awaited:
// Kick off processing: a Hatchet job when available, else inline on the long-running SSR runtime.
async function kickProcessing(id: string): Promise<void> {
if (isJobsEnabled()) {
const { getHatchet } = await import("@tikab-interactive/fusion-jobs/hatchet");
await getHatchet()
.admin.runWorkflow("process-chat-document", { documentId: id })
.catch(() => processChatDocument(id));
} else {
await processChatDocument(id);
}
}Raw HTTP routes (when you need the request itself)
Server functions cover almost everything, but a few cases need the raw Request/Response
— streaming responses, serving bytes, an auth callback. Those live in src/routes/api/ as
API routes: a route file with a server.handlers object keyed by HTTP method. They run
only on the server (same compile boundary — the body never reaches the client), but you own
the request and response.
The object-serving route is the minimal example:
import { createFileRoute } from "@tanstack/react-router";
import { getObject } from "@tikab-interactive/fusion-storage";
// Serves a stored object by key — the URL fusion-storage's `fileUrl()` builds
// (`/api/files?key=…`). Server-only API route, so the storage driver never
// enters the client bundle.
export const Route = createFileRoute("/api/files")({
server: {
handlers: {
GET: async ({ request }) => {
const key = new URL(request.url).searchParams.get("key");
if (!key) return new Response("Missing key", { status: 400 });
const object = await getObject(key);
if (!object) return new Response("Not found", { status: 404 });
return new Response(new Uint8Array(object.body), {
headers: {
"Content-Type": object.contentType,
"Cache-Control": "private, max-age=3600",
},
});
},
},
},
});The chat endpoint is the other reason you'd reach for this: the LLM reply is a streamed
response, which a JSON-returning server function can't express. So /api/agent/chat is an
API route that checks the session by hand and returns a stream:
POST: async ({ request }) => {
const session = await getSession();
if (!session) return new Response("Unauthorized", { status: 401 });
// …build the owner-scoped tools + system prompt…
return streamChat(request, {
systemPrompts: [system],
tools: createAgentAssistantTools(actor, scope.kind, threadId),
agentLoopStrategy: maxIterations(6),
});
},Same security boundary as a server function — getSession() here, an explicit 401, and
tools that are owner-scoped to session.user.id — just expressed as a raw handler because
the payload is a token stream, not a value. (AI covers streamChat.)
The whole picture
Where each piece lives
| File | Role in the lifecycle |
|---|---|
src/start.ts | request middleware — CSRF + locale, wraps every request incl. the render |
src/routes/__root.tsx | the SSR document shell + the hydration marker |
src/routes/_authed.tsx | beforeLoad auth gate + loader + user on the route context |
src/lib/*-server.ts | every server function — the validated, owner-scoped RPC handlers |
src/routes/api/* | raw HTTP routes (streaming chat, byte serving, auth callback) |
route files (loader) | data fetched before render, fed to the component via useLoaderData() |
Keep going
- How TanStack Start works — the four ideas, if you skipped the prerequisite.
- Authorization — the second half of every handler: validators vs guards, list-then-recheck, the shared permission registry.
- Data — the Drizzle calls inside the handlers, and why every query is owner-scoped.
- Sandbox — the catalogue of client-only packages and the SSR-safety pattern.
- Jobs — where to put the background work SSR would otherwise drop.