Coming from React
You already know React — components, hooks, JSX. TanStack Start keeps all of that and
adds a server. The components in example/src/ are ordinary React; what changes is where
data comes from and where the first render happens. This page maps your existing SPA
habits onto Start. If you haven't yet, read How TanStack Start works
first — this is the side-by-side.
At a glance
| In a React SPA you… | In TanStack Start you… |
|---|---|
wire routes with <Routes>/<Route> or a config array | drop a file in src/routes/ — the filename is the URL |
fetch in useEffect, juggle loading/error/data state | declare a route loader; the data is there at first render |
write a REST/GraphQL endpoint + a fetch wrapper | write a server function and call it like an async function |
| reach for React Query / SWR / Redux for server state | let loaders own server state; router.invalidate() to refetch |
ship an empty <div id="root">, boot React, then paint | the server renders HTML; React hydrates it |
keep a token in localStorage, add fetch interceptors | a session cookie + a beforeLoad guard put user on route context |
guard routes with a <RequireAuth> wrapper component | nest under _authed.tsx; its beforeLoad redirects + provides the user |
The one habit to unlearn: useEffect for data
This is the single biggest adjustment. In a SPA:
// ❌ SPA reflex — fetch after mount
function Assignments() {
const [rows, setRows] = useState<Row[] | null>(null);
useEffect(() => {
fetch("/api/assignments")
.then((r) => r.json())
.then(setRows);
}, []);
if (!rows) return <Spinner />;
return <Table rows={rows} />;
}That fetches after the component mounts on the client — so the first paint is a spinner, there's no SSR, and you hand-roll the loading/error states. In Start:
// ✅ Start — data loads on the server, before render
export const Route = createFileRoute("/_authed/.../assignments")({
loader: ({ params }) => listProjectAssignments({ data: { projectKey: params.projectKey } }),
component: Assignments,
});
function Assignments() {
const rows = Route.useLoaderData(); // already here, fully typed
return <Table rows={rows} />;
}The loader runs on the server during SSR, the HTML ships complete, and hydration restores
the data without a second fetch. You still use useState/useEffect for genuine UI state
(a toggle, a debounce, a map that must mount client-side) — just not as the data-loading
mechanism.
"Where's the API?" — there isn't one
A React SPA needs a backend you call over HTTP. Start collapses that: a server function is the backend, imported and called directly.
export const listProjectAssignments = createServerFn({ method: "GET" })
.inputValidator(keyInput)
.handler(async ({ data }): Promise<ProcessnAssignmentRow[]> => {
const { viewer, building } = await viewerAndBuilding(data.projectKey);
if (building.processnProjectId === null) return [];
const assignments = await db
.select({
id: processnAssignedTask.id,
comment: processnAssignedTask.comment,
taskName: processnTask.name,
assignee: user.name,
project: processnProject.name,
status: processnTaskStatus.name,
})
.from(processnAssignedTask)
.innerJoin(processnTask, eq(processnAssignedTask.taskId, processnTask.id))
.innerJoin(user, eq(processnAssignedTask.userId, user.id))
.leftJoin(processnProject, eq(processnAssignedTask.projectId, processnProject.id))
.leftJoin(processnTaskStatus, eq(processnAssignedTask.statusId, processnTaskStatus.id))
.where(
and(
visibleProcessnAssignmentsWhere(viewer),
eq(processnAssignedTask.projectId, building.processnProjectId),
),
);
const rows: ProcessnAssignmentRow[] = [];
for (const a of assignments) {
const perms = await getAssignedTaskPermissions(db, viewer.id, a.id);
rows.push({
id: a.id,
taskName: a.taskName,
assignee: a.assignee,
project: a.project,
status: a.status,
comment: a.comment,
canChange: perms.canChange,
});
}
return rows;
});- No URL, no
fetch, no response parsing. Youawait listProjectAssignments({ data }). - Types flow end to end. The return type is what
useLoaderData()gives you — no shared API-types package, no codegen. - The handler body never reaches the browser. It's stripped from the client bundle, so
the
dbclient, secrets, and thegetSession()check (insideviewerAndBuilding) stay server-side. The function file is the trust boundary — there's no"use server"directive to remember (that's a Next.js thing; Start uses thecreateServerFncall instead).
SSR gotchas you didn't have in a SPA
Because your component now also runs on the server, a few things bite:
- No
window/documentat module scope or in render. They don't exist on the server. Anything that touches the DOM or a browser-only library (MapLibre, a charting lib) must be client-only — lazy-load it behind a mounted flag. The GIS page shows the exact pattern, and the sandbox has a checklist. - Hydration must match. The server HTML and the first client render have to be
identical, so don't branch render output on
typeof window, random values, orDate.now()during render. - Fire-and-forget doesn't survive. A timer or background promise started during SSR is thrown away when the response is sent — push that work to a job instead.
What stays exactly the same
- Components, JSX, hooks, context,
useState/useReducer— all normal React. - Styling and components come from fusion-ui (Mantine under the hood) — you don't author raw UI, you wire data into it.
- Client interactivity — event handlers, optimistic updates, animations: same as ever.
To write data, call a
POSTserver function from a handler, thenrouter.invalidate()to re-run the loader.
Ready to build one? Building a feature walks the full round trip, and the apps show it at scale. Working with a Django dev too? Send them to Coming from Django.