Skip to content
Fusion

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):

example/src/lib/auth-server.ts
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:

example/src/lib/carola-threads-server.ts
/**
 * 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:

Loading diagram...

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:

example/src/lib/carola-threads-server.ts
/** 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 data is well-formedid non-empty, title 1–160 chars. Shape only.
  • The handler guarantees the caller is allowedrequireSession() (401 if not signed in) and the eq(carolaThread.ownerId, session.user.id) clause, so the UPDATE only ever matches this user's row. A valid payload with someone else's thread id changes 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:

example/src/lib/chat-document-server.ts
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.

example/src/routes/_authed.project.$projectKey.index.tsx
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:

example/src/components/chat/ConversationHeaderBar.tsx
	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:

example/src/routes/_authed.project.$projectKey.index.tsx
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):

example/src/routes/_authed.tsx
	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:

example/src/routes/__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:

example/src/start.ts
export const startInstance = createStart(() => ({
	// localeMiddleware first so its locale context wraps everything downstream,
	// the render included.
	requestMiddleware: [localeMiddleware, csrfMiddleware],
}));
  • csrfMiddleware rejects state-changing verbs (POST/PUT/…) that fail a same-origin check — this is what makes the POST server functions safe. Safe verbs (GET/HEAD) stay open so navigations work.
  • localeMiddleware runs the rest of the request inside Paraglide's AsyncLocalStorage context, so getLocale() 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:

example/src/components/briefing/CarolaInline.tsx
	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.

example/src/routes/__root.tsx
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:

example/src/lib/chat-document-server.ts
// 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:

example/src/routes/api/files.ts
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:

example/src/routes/api/agent/chat.ts
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

Loading diagram...

Where each piece lives

FileRole in the lifecycle
src/start.tsrequest middleware — CSRF + locale, wraps every request incl. the render
src/routes/__root.tsxthe SSR document shell + the hydration marker
src/routes/_authed.tsxbeforeLoad auth gate + loader + user on the route context
src/lib/*-server.tsevery 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.