Skip to content
Fusion

How TanStack Start works

The example app is a TanStack Start app. If you've written React but not Start, this page is the one to read first — it explains the four ideas that make Start different from a plain React SPA, using the real files in example/. Coming from a specific background? Pair this with Coming from React or Coming from Django.

Start is a full-stack React framework: the same React components you already write, plus a server that renders them to HTML (SSR), loads their data, and runs your server-only code. It's assembled from three pieces you'll see in vite.config.ts:

  • TanStack Router — type-safe, file-based routing.
  • Vite — the dev server and bundler (tanstackStart() plugin).
  • Nitro — the production server runtime that handles requests, runs server functions, and renders React.

You write components and a handful of *-server.ts files. Start wires the rest.

The four ideas

IdeaOne linePlain-React equivalent
File-based routesa file in src/routes/ is a URLyou wire <Route> elements by hand
Server functionsan async function that runs only on the servera REST/GraphQL endpoint you write + fetch
Loadersa route fetches its data before it rendersuseEffect(() => fetch(...)) after mount
SSRthe server sends finished HTML, then React hydrates itthe browser boots an empty <div id=root>

If you internalise those four, the rest of the app reads naturally. The sections below take them one at a time.

1. File-based routing

Every file under src/routes/ becomes a route. The filename is the URL, and a build step compiles them into src/routeTree.gen.ts (generated — never edit it). You never maintain a route table by hand.

The naming conventions you'll see:

FileMeans
__root.tsxthe root layout — wraps every page (the <html> shell)
index.tsx/
_authed.tsxa pathless layout — adds no URL segment, but everything nested under it is wrapped + guarded (note the leading _)
_authed.home.tsx/home, rendered inside the _authed layout
_authed.project.$projectKey.tsx/project/:projectKey$ marks a URL parameter
_authed.project.$projectKey.nyhetn.tsx/project/:projectKey/nyhetn

Dots are path separators; a leading underscore makes a layout (shared UI + logic that doesn't consume a path segment). That single convention is how the whole app gets "every signed-in page sits in the app shell and requires a session" for free — it's all nested under _authed.tsx.

A route file exports one Route — here's the real /home route (its loader fans out to several server functions; more on loaders in §3):

example/src/routes/_authed.home.tsx
export const Route = createFileRoute("/_authed/home")({
	// `?scope=` carries Carola's scope so switching it re-lenses the conversation in place
	// (pathname stays `/home`) and a reload / deep-link restores it. The provider resolves the
	// value (a project key, or "portfolio" / "general") against the viewer's projects.
	validateSearch: (search: Record<string, unknown>): { scope?: string } => ({
		scope: typeof search.scope === "string" ? search.scope : undefined,
	}),
	loader: async () => {
		const [buildings, findings, approvals, deadlines] = await Promise.all([
			getPortfolio(),
			listMyNotifications(),
			listMyApprovals(),
			listMyDeadlines(),
		]);
		return { buildings, findings, approvals, deadlines };
	},
	component: Home,
});

2. Server functions — the API you don't write

This is the biggest shift from a SPA. A server function is an async function that only ever runs on the server. Its body is stripped from the browser bundle; the client keeps a typed stub that, when called, does an RPC to the server and returns the result. You never write a controller, a route handler, or a fetch — and the types flow end to end.

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;
});

Three things to notice (focus on fetchSession — the second function, getEdgeSsoEnabled, is the same pattern applied to a config flag):

  • createServerFn({ method }) declares the HTTP verb. .handler(async …) is the server-only body. The return value must be JSON-serialisable — it's the response.
  • It's the security boundary. The handler reads the database, checks the session, and enforces permissions. The browser can't see or skip any of that.
  • You call it like a function. From a loader or a component: const session = await fetchSession(). No URL, no fetch wrapper, no response parsing.

Writes look the same with method: "POST" and an input validator (the body is validated on the server before your handler runs):

export const updateAssignment = createServerFn({ method: "POST" })
	.inputValidator(z.object({ id: z.number(), comment: z.string() }).parse)
	.handler(async ({ data }) => {
		// `data` is validated + typed
		const userId = await requireUserId(); // 401 if no session
		/* …re-check permission on this row, then write… */
	});

example/src/lib/ is all server functions (every *-server.ts). There is no separate api/ folder, because there is no separate API.

3. Loaders — data before render

A route declares a loader. It runs on the server during the initial request (and on navigation), before the component renders. The component reads the result synchronously — the data is already there.

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} />;
}

Coming from React, the instinct is useEffect(() => { fetch(...).then(setState) }, []). Don't. That pattern fetches after the component mounts on the client, which means an empty first paint, a loading spinner, and no SSR. A loader fetches before render, on the server, so the HTML ships complete. Reach for client-side fetching only for genuinely post-load interactions (and even then, prefer another server function called from an event handler, followed by router.invalidate() to re-run the loader).

Route.useRouteContext() is the sibling hook: it reads data put on the route context by beforeLoad (see next). In any _authed page, Route.useRouteContext().user is the signed-in { id, name, email, isSiteAdmin }.

4. SSR + the request lifecycle

Start renders your React on the server and sends finished HTML; the browser then hydrates it (attaches event handlers to the already-painted markup). So the first paint is real content, not an empty shell — good for perceived speed, SEO, and no-JavaScript resilience.

Here's a full request to a guarded page:

Loading diagram...
  • beforeLoad runs first and can throw redirect(...). The _authed layout uses it as the auth gate (no session → /login) and returns { user } onto the route context, so every nested page has the user without re-fetching.
  • Request middleware (src/start.ts) wraps every request: a CSRF check on state-changing verbs, and a locale wrapper so getLocale() is correct during SSR (i18n).
  • Because the same component code runs on both sides, libraries that touch the DOM (e.g. MapLibre) must be client-only — lazy-loaded behind a mounted flag. The GIS page shows the pattern.

Where it's wired in the example

You rarely touch these, but it helps to know where the machinery lives:

FileRole
src/router.tsxcreates the router from the generated routeTree
src/routes/__root.tsxthe SSR document shell (<html>, fonts, FusionProvider, <Scripts/>)
src/routes/_authed.tsxthe auth gate + app shell — every signed-in page nests here
src/start.tsrequest middleware (CSRF, locale)
vite.config.tsthe tanstackStart() + nitro() plugins, and the React/Drizzle dedupe list
src/routeTree.gen.tsgenerated route table — never edit

The shape of every feature

Put together, a feature in this app is always the same four parts — covered hands-on in Building a feature:

Loading diagram...
  1. a route file (src/routes/_authed.<name>.tsx) — the URL + the loader,
  2. a server function (src/lib/<name>-server.ts) — the data + the permission checks,
  3. a component — ordinary React, with UI from fusion-ui,
  4. optionally a nav link in _authed.tsx.

Next: see how this maps onto what you already know — Coming from React or Coming from Django — then tour the apps built on it, starting with the Portfolio.