Skip to content
Fusion

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 arraydrop a file in src/routes/the filename is the URL
fetch in useEffect, juggle loading/error/data statedeclare a route loader; the data is there at first render
write a REST/GraphQL endpoint + a fetch wrapperwrite a server function and call it like an async function
reach for React Query / SWR / Redux for server statelet loaders own server state; router.invalidate() to refetch
ship an empty <div id="root">, boot React, then paintthe server renders HTML; React hydrates it
keep a token in localStorage, add fetch interceptorsa session cookie + a beforeLoad guard put user on route context
guard routes with a <RequireAuth> wrapper componentnest 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.

example/src/lib/project-apps-server.ts
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. You await 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 db client, secrets, and the getSession() check (inside viewerAndBuilding) 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 the createServerFn call 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/document at 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, or Date.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 POST server function from a handler, then router.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.