Lesson 6 · Frontend for backend developers
Everything so far ran in the browser. Here is where your backend instincts become an advantage again.
Lessons 1–5 built a single-page application: Vite compiles
your code, the browser downloads it, and React builds the entire page client-side
from an empty <div>. That model has two well-known costs — the
first paint waits for a JavaScript download, and a crawler that does not execute
JavaScript sees nothing.
TanStack Start is Vite plus React plus a server. It renders the first response on the server, ships the HTML, then lets React take over in the browser. It adds three things to what you already know.
In a Vite SPA there are no URLs — one page, and you add a router yourself. In
Start, files under src/routes/ are the URL structure:
src/routes/
├── __root.tsx the HTML shell wrapping every page
├── index.tsx /
├── about.tsx /about
└── tasks/
├── index.tsx /tasks
└── $taskId.tsx /tasks/123 ← $ marks a URL parameter
A route file exports a Route. TypeScript then knows your URLs:
<Link to="/tasks/$taskId" params={{ taskId: '5' }} /> is checked
at build time, so a typo in a link is a compile error rather than a 404 a user
finds.
In the SPA model you render, then fetch, then re-render with the data —
which is why SPAs flash spinners. A loader inverts that: the data is
fetched first, and the component receives it already populated.
src/routes/tasks/index.tsx
export const Route = createFileRoute('/tasks/')({
loader: async () => await getTasks(),
component: TasksPage,
})
function TasksPage() {
const tasks = Route.useLoaderData() // already here, fully typed
return <TaskList tasks={tasks} />
}
A loader is a view function that prepares the context before the template
renders. Route.useLoaderData() is reading that context. The
difference from Django is that the same route also runs client-side on
subsequent navigations — which is the next point, and the one that trips
people up.
A loader runs on both the server and the client. On first page load it runs on the server; when the user navigates within the app, the same loader runs in the browser. So a loader is not a safe place for secrets or database access.
Server-only code goes in a server function. Call it from anywhere; it always executes on the server:
import { createServerFn } from '@tanstack/react-start'
export const getTasks = createServerFn({ method: 'GET' })
.handler(async () => {
return db.task.findMany() // never reaches the browser bundle
})
Called from the client, that becomes an HTTP request with the response typed end-to-end. No route to define, no fetch to write, no response type to keep in sync by hand — the function signature is the API contract.
Writes take a validator, which parses untrusted input at the
boundary before the handler sees it:
export const addTask = createServerFn({ method: 'POST' })
.validator((d: { title: string }) => d)
.handler(async ({ data }) => {
return db.task.create({ data: { title: data.title } })
})
.validator() is your Pydantic model on a FastAPI endpoint, and
it is there for the same reason: input arriving over the network is untrusted,
and the type annotation alone proves nothing at runtime. In production you
would pass a real Zod schema here rather than the identity function above.
Why can't you read process.env.DATABASE_URL inside a
route loader?
Scaffold. The CLI asks about Tailwind, add-ons and package manager — say yes to Tailwind and you can skip Lesson 2's install steps.
npx create-start-app@latest my-app
cd my-app
npm install
npm run dev
Read src/routes/__root.tsx first. It contains the actual
<html> and <body> tags — because the
server renders the whole document, not just a div. That file is where global
CSS is imported and where <Outlet /> marks the hole child
routes render into.
Add src/routes/hello.tsx, save, and visit
/hello. The route exists with no registration step — that is
file-based routing.
Then prove the boundary to yourself: put a console.log in a
loader. It prints in your terminal on first load, and in the
browser console when you navigate to the route with a
<Link>. Seeing that once will teach you more than this
lesson did.
| From Lessons 1–5 | Status in Start |
|---|---|
| Components, props, callbacks | Identical. It is the same React. |
Lifting state, composition with children | Identical. |
| Tailwind | Identical — Start is a Vite app, so it is the same plugin. |
useState for server data | Mostly gone. Loaders own that now. |
| Context for the current user | Often replaced by router context, which is typed and available in loaders. |
From memory: where does a loader run, where does a server function run, and what follows for secrets?
A loader runs on the server for the initial request and in the browser on client-side navigation. A server function always runs on the server. Secrets and direct database access therefore belong only in server functions — anything in a loader may be shipped to the browser.