Lesson 6 · Frontend for backend developers

TanStack Start: the server comes back

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.

1. Routing is the file system

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.

2. Loaders fetch before the component renders

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} />
}
In your language

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.

3. Server functions, and the boundary

The thing to actually remember

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 } })
  })
In your language

.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?

Setting one up

  1. 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
  2. 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.

  3. Add src/routes/hello.tsx, save, and visit /hello. The route exists with no registration step — that is file-based routing.

  4. 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.

What carries over, and what doesn't

From Lessons 1–5Status in Start
Components, props, callbacksIdentical. It is the same React.
Lifting state, composition with childrenIdentical.
TailwindIdentical — Start is a Vite app, so it is the same plugin.
useState for server dataMostly gone. Loaders own that now.
Context for the current userOften 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.

Ask Martin. The best use of this lesson is to open the real work repo together and locate: the routes directory, one loader, one server function, and the root route. Everything above is scaffolding for reading that code.