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
| Idea | One line | Plain-React equivalent |
|---|---|---|
| File-based routes | a file in src/routes/ is a URL | you wire <Route> elements by hand |
| Server functions | an async function that runs only on the server | a REST/GraphQL endpoint you write + fetch |
| Loaders | a route fetches its data before it renders | useEffect(() => fetch(...)) after mount |
| SSR | the server sends finished HTML, then React hydrates it | the 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:
| File | Means |
|---|---|
__root.tsx | the root layout — wraps every page (the <html> shell) |
index.tsx | / |
_authed.tsx | a 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):
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.
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.
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:
beforeLoadruns first and canthrow redirect(...). The_authedlayout 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 sogetLocale()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:
| File | Role |
|---|---|
src/router.tsx | creates the router from the generated routeTree |
src/routes/__root.tsx | the SSR document shell (<html>, fonts, FusionProvider, <Scripts/>) |
src/routes/_authed.tsx | the auth gate + app shell — every signed-in page nests here |
src/start.ts | request middleware (CSRF, locale) |
vite.config.ts | the tanstackStart() + nitro() plugins, and the React/Drizzle dedupe list |
src/routeTree.gen.ts | generated 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:
- a route file (
src/routes/_authed.<name>.tsx) — the URL + the loader, - a server function (
src/lib/<name>-server.ts) — the data + the permission checks, - a component — ordinary React, with UI from fusion-ui,
- 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.