Hedronite · Frontend Dev · TypeScript · 2026-10-18

Loaders, server functions and typed routes in TanStack Start one route file, two runtimes

Know which lines run on the server, which run in the browser, and what the build does to keep them apart.

Lesson Class: Dev
Stack: TanStack Start 1.169 · React 19 · Vite 8 · Node 26 · TypeScript 6
Source: TanStack Start and Router docs · Learning React (Banks, Porcello), ch. 12
The route tree types the links
routeTree.gen.ts registers the router, so a wrong path or missing params is a compile error.
Loaders run in both places
The server runs them for the first page; the browser runs them on navigation. Secrets never go in a loader.
Server functions are the boundary
createServerFn bodies stay on the server; the browser gets a same-origin RPC stub.

The problem

You have a React app that lists notes. The server's first response is an empty HTML shell with a script tag, so readers see a blank page until the JavaScript downloads, runs and fetches the notes from an API. The notes live in a file or a database on the server, next to secrets such as a connection string, and none of that may reach the browser. So the repository holds two codebases: API handlers on one side and fetch calls on the other. The only thing joining them is a URL string. Rename a field on the server and the client still compiles, then fails at runtime.

Frameworks that keep a page and its data in one file remove that drift. They also add a new trap. When one file holds both halves, a line that looks like private server code can run in the browser, and a line that looks browser-only can run on the server first. A secret read in the wrong place does not crash. It quietly becomes undefined in front of users, and nothing warns you.

The notes app below is small enough to check every claim against real build output, browser traffic and server logs, so each section shows which lines ran where and what the framework did to stop the dangerous ones.

The idea, in plain words

TanStack Start is a full-stack React framework built on TanStack Router, so every URL is a file. A route file names the URL, declares a loader that fetches the page's data, and holds the component that renders it. For the first visit, the server runs the loader and produces the page's HTML. That is server-side rendering (SSR). The browser then takes over that HTML and reuses the data. After that, clicking a link runs the same loader in the browser. Anything private goes into a server function. You call it like any async function, but the build replaces it with a network call in the browser, and the real code stays on the server. A generated file called the route tree tells TypeScript about every URL and function, so a wrong link or a wrong call is a compile error.

Concept A: file routes and the generated route tree

Tested with Node.js 26.8.2, Bun 1.4.2 (package installer only: bun add and bun run build, while the server ran on Node.js), Vite 8.3.4, @vitejs/plugin-react 6.1.2, React 19.3.1, @tanstack/react-start 1.169.3, @tanstack/react-router 1.170.45, @tanstack/router-plugin 1.168.47 (pulled in by Start), zod 4.6.7, TypeScript 6.0.3, oxlint 1.88.0 and srvx 1.0.6.

File-based routing is a way to configure routes using the filesystem: each file under src/routes becomes one URL. The route tree is the data structure that lists every route with its parent and full path. Start produces it through code generation, meaning a build step writes a source file for you, here src/routeTree.gen.ts. A path parameter is a URL segment that varies from page to page. It is marked with $ in the file name.

Without this, you maintain a hand-written table of URLs and write every link as a plain string. A typo in a link is then a 404 that the compiler cannot see. The Router documentation says file-based routing "raises the ceiling on type-safety by generating and managing type linkages for your routes". The generated tree is where those linkages live, so that a link can be checked against the real set of routes.

Start needs two hand-written files beyond the routes: a router configuration and a root route. Plugin order matters in vite.config.ts, and the Start documentation also advises keeping verbatimModuleSyntax disabled in tsconfig.json, because enabling it can leak server code into client output.

// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'

export default defineConfig({
  server: { port: 3000 },
  plugins: [
    tanstackStart(),
    // React's plugin must come after Start's plugin
    viteReact(),
  ],
})

The tanstackStart() plugin is what turns a plain Vite project into a Start project: it generates the route tree and produces the separate client and server builds you will see later in the build log. The comment is the documentation's rule, kept in the file so nobody reorders the array: React's plugin must come after Start's. The documentation also asks for "type": "module" in package.json and for the dev and build scripts to call Vite.

// src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

export function getRouter() {
  return createRouter({
    routeTree,
    scrollRestoration: true,
  })
}

getRouter() is the router configuration Start asks for. It imports routeTree from the generated file and passes it to createRouter, so the router knows every route without a hand-written table. scrollRestoration: true is the documentation's starter setting. createRouter, like every route helper in this app, comes from @tanstack/react-router, because Start relies entirely on TanStack Router for routing. The routes directory in the lab app has three files:

src/routes/__root.tsx      the root route: wraps every page
src/routes/index.tsx       URL /
src/routes/notes.$id.tsx   URL /notes/$id

The generated file begins with a warning and then lists every full path. Here is an excerpt:

// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
...
export interface FileRoutesByFullPath {
  '/': typeof IndexRoute
  '/notes/$id': typeof NotesIdRoute
}
...
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
  interface Register {
    ssr: true
    router: Awaited<ReturnType<typeof getRouter>>
  }
}

The last block is a TypeScript module augmentation: it adds a router field to the framework's Register interface, so that every Link, useLoaderData and params type in the app is computed from this router's routes. Nothing in it runs at runtime; it exists only for the type checker, which is why deleting it changes compile errors but not the built app.

The two links in the app point at routes from that list:

<nav>
  <Link to="/">All notes</Link>
</nav>
...
<Link to="/notes/$id" params={{ id: String(n.id) }}>
  {n.text}
</Link>

In the walk-through, the name notes.$id.tsx maps to /notes/$id because a . in a flat route file name denotes nesting and $id is the path parameter. index.tsx is the exact / route, and __root.tsx wraps everything. The root component renders the <html> document, <HeadContent /> for the title and meta tags, and <Scripts />, which loads the JavaScript that makes the page interactive. The routeTree.gen.ts file does not exist on a fresh checkout, so router.tsx cannot compile until Start has run once. The documentation names dev and start, and in the lab vite build also generated the file. Once it exists, tsc --noEmit and oxlint src both exit 0. The params value is a string because URL segments are strings, which is why notes.$id.tsx later converts it with Number(params.id).

Common mistake: a link to a route that does not exist.

Symptom. tsc --noEmit fails on the link:

src/routes/index.tsx(39,19): error TS2820: Type '"/note/$id"' is not assignable to type '"." | ".." | "/" | "/notes/$id"'. Did you mean '"/notes/$id"'?

After correcting the path but dropping params, the compiler complains again:

src/routes/index.tsx(39,14): error TS2741: Property 'params' is missing in type '{ children: string; to: "/notes/$id"; }' but required in type 'MakeRequiredPathParams<...>'.

Cause. The union of allowed paths comes from the generated route tree. The file also ties the router's type into the framework, and that is the only reason Link knows the real routes. The first error means /note/$id is not a route. The second means /notes/$id has a path parameter that the link did not supply.

Fix. Use the suggested path and pass params. Keep in mind the quieter version of this failure. In the lab, deleting the declare module block from routeTree.gen.ts made the bad link compile: TS2820 disappeared, and the only remaining error was src/routes/index.tsx(37,21): error TS7006: Parameter 'n' implicitly has an 'any' type. because the loader data had lost its type as well. If loader data turns into any and wrong links stop failing, the generated file is missing, stale or hand-edited. Run the dev server or the build to regenerate it, and never edit it by hand.

Concept B: loaders are isomorphic

A route loader is an async function attached to a route that fetches the data the page needs before it renders. The component reads the result with Route.useLoaderData(). Code is isomorphic when the same source can run on several platforms. Learning React puts it this way: "Isomorphic applications are applications that can be rendered on multiple platforms." Start's execution model says all of its code is isomorphic by default, and that route loaders "run on both server and client, not just the server". Hydration is the step where React in the browser attaches to HTML the server already produced, instead of building the page from scratch. The book describes the order: render a static version so the user sees the page has loaded, request the dynamic JavaScript, replace the static content with the dynamic content, and then the user's clicks work.

This design gives you two things. The loader runs on the server for the first request, so the HTML arrives with the notes already inside it. That removes the empty shell. Hydration then reuses that result, so the browser does not repeat the request. After hydration, the loader runs in the browser when you navigate, so the next page can be built without a new document. The cost is the trap from the opening: because the same function runs in two places, anything it touches must exist in both.

Here is the loader and the save handler from src/routes/index.tsx. The console.log line exists only so the lab can show where the loader runs:

export const Route = createFileRoute('/')({
  loader: async () => {
    console.log(`[index loader] running on the ${typeof window === 'undefined' ? 'server' : 'client'}`)
    return listNotes()
  },
  component: Home,
})

function Home() {
  const notes = Route.useLoaderData()
  const router = useRouter()
  ...
  async function save() {
    try {
      await addNote({ data: { text } })
    } catch {
      // The validator's rejection arrives here as a thrown Error.
      setError('A note needs 1 to 200 characters.')
      return
    }
    setText('')
    setError(null)
    // Re-run the matched routes' loaders and wait for them to finish.
    await router.invalidate({ sync: true })
  }
  ...
}

The loader calls listNotes(), a server function defined in the next section, and returns its result. typeof window === 'undefined' is true only outside a browser, so the log line names the side that ran it. Route.useLoaderData() returns the loader's value with its type inferred from the loader, which is why notes.length and n.text type-check without annotations. save() sends the new note, shows a message if the server rejects it, and then asks the router to run the loaders again.

A headless Chrome probe loaded /, added a note, clicked its link, clicked "All notes", and loaded /notes/99. The first part of its output was:

h1 after SSR: Notes (0)
[browser request] POST /_serverFn/ef07003121295dccd68185cf34cc1...
[browser console] [index loader] running on the client
[browser request] GET /_serverFn/d1bfc0fb54611c74d719a3a71940f...
h1 after add: Notes (1)

In the walk-through, h1 after SSR shows the heading was already in the server's HTML. After the server-rendered page loaded, the first server-function request was the POST for the new note. No GET for the list happened during hydration, so the browser reused the server's data. The running on the server line appears only in the server's log. The line running on the client appears after the POST because router.invalidate re-ran the loader, and this time the browser ran it and fetched the fresh list.

The code uses { sync: true } on purpose. By the Router documentation, router.invalidate() marks matching loader data as stale, and "by default, stale successful loader data revalidates in the background", so awaiting the plain call does not promise that fresh data is on screen. With sync: true the call waits until all loaders have finished. So save() settles only when the list on screen is up to date, and Notes (1) is already true when the click handler completes.

Common mistake: reading a secret in a loader.

Symptom. A loader that reads process.env.NOTES_ADMIN_KEY gives two answers. The server was started with NOTES_ADMIN_KEY=s3cret-runtime. On the first page load its log says:

[index loader] running on the server
[index loader] admin key present: true

After clicking from /notes/1 to "All notes", the browser console says:

[browser console] [index loader] running on the client
[browser console] [index loader] admin key present: false

Cause. The loader is isomorphic, so the browser runs it too, and the browser has no server environment. In the client JavaScript the expression compiled to let e={}.NOTES_ADMIN_KEY;, an empty object, so the value is always undefined. Start's execution model lists this as an anti-pattern: the loader "runs on BOTH server and client!".

Fix. Read the variable inside a server function handler, or inside createServerOnlyFn, and call that from the loader. The execution model guide reserves the VITE_ prefix for values that are meant to be public in the browser; everything else belongs behind a server function.

Concept C: server functions are the RPC boundary

A server function is a function that is defined with createServerFn, runs only on the server, and can be called from loaders, components, hooks or other server functions. It is an RPC (remote procedure call): the caller writes an ordinary function call, and the framework turns it into a network request. Serialization is the conversion of values into a transportable format. Start requires serializable validator inputs and return values, because they cross the network. A validator is a schema checked against incoming data before the handler runs. Import protection is a build-time check that stops server-only files from reaching the browser's JavaScript.

Without this boundary you have two bad options. You can write API routes by hand and keep both ends in sync yourself, or you can put database code in a loader and watch it run in the browser. A server function gives you one typed function. The build replaces its body with a network call in the browser. As Start's documentation puts it: "The actual server code never reaches the browser."

The lab app follows the file naming convention from the docs. A .server.ts file holds server-only code, and a .functions.ts file exports createServerFn wrappers that are safe to import anywhere. The server-only file reads and writes notes.json:

// src/notes.server.ts
// Server-only: the .server.ts suffix makes Start refuse to include this file in the browser build.
import { readFile, writeFile } from 'node:fs/promises'

export type Note = { id: number; text: string }

const FILE = process.env.NOTES_FILE ?? 'notes.json'

export async function readNotes(): Promise<Note[]> {
  try {
    return JSON.parse(await readFile(FILE, 'utf-8')) as Note[]
  } catch {
    return []
  }
}

export async function appendNote(text: string): Promise<Note> {
  const notes = await readNotes()
  const note = { id: notes.length + 1, text }
  await writeFile(FILE, JSON.stringify([...notes, note], null, 2))
  return note
}

This file imports node:fs/promises, which exists only in Node.js, and it is the only file in the app that touches the disk. Its .server.ts suffix is not decoration: import protection uses the name to refuse any browser-side import of it, as the mistake at the end of this section shows. It also reads process.env.NOTES_FILE at module scope. That read is safe from the browser here, because this file can never reach the browser's JavaScript. The execution model guide still warns that on runtimes that inject environment variables per request, such as Cloudflare Workers, a module-level read evaluates to undefined even on the server; on those platforms, move the read inside the function. The server functions wrap it:

// src/notes.functions.ts
import { createServerFn } from '@tanstack/react-start'
import { notFound } from '@tanstack/react-router'
import { z } from 'zod'
import { appendNote, readNotes } from './notes.server'

export const listNotes = createServerFn({ method: 'GET' }).handler(async () => {
  return readNotes()
})

export const getNote = createServerFn({ method: 'GET' })
  .validator(z.object({ id: z.number().int().positive() }))
  .handler(async ({ data }) => {
    const note = (await readNotes()).find((n) => n.id === data.id)
    if (!note) throw notFound()
    return note
  })

export const addNote = createServerFn({ method: 'POST' })
  .validator(z.object({ text: z.string().trim().min(1).max(200) }))
  .handler(async ({ data }) => {
    return appendNote(data.text)
  })

In the walk-through, GET is the default method, and the lab sets it explicitly for reads and uses POST for the write. A server function takes a single data parameter, which is why calls look like addNote({ data: { text } }). The validators are zod schemas passed to .validator(). In addNote, the schema trims the text before checking length, so three spaces become an empty string and fail min(1). In getNote, a missing note throws notFound(), which Start handles when the function is called from a route loader. The route then renders its notFoundComponent.

The build shows the boundary directly. The client JavaScript contains the loader, so the loader ships to the browser, but each server function became a small stub that calls a URL:

function Ps(e){let t=`/_serverFn/`+e;return Object.assign((...e)=>{let n=S()?.serverFns?.fetch;return Os(t,e,n??fetch)},{url:t,serverFnMeta:{id:e},...})}

A search of every client JavaScript file for readFile, notes.json and node:fs found 0 matches each. The server-side copy of notes.functions.ts maps each function to a 64-hex-character id, which becomes the URL:

var listNotes = createServerFn({ method: "GET" }).handler(createSsrRpc("d1bfc0fb54611c74d719a3a71940f605813ca0717658d9df7c7553f4be3ab814"));

Validation failures are visible too. When the probe clicked Add with three spaces, using a first version of save() with no try/catch, the response status was 200 and the serialized body carried an error:

[response] POST 200 "application/json" {"t":10,"i":0,"p":{"k":["error"],"v":[{"t":25,"i":1,"s":{"message":{"t":1,"s":"[\n  {\n    \\"origin\\": ..."
[pageerror] [
  {
    "origin": "string",
    "code": "too_small",
    "minimum": 1,
    ...
    "message": "Too small: expected string to have >=1 characters"
  }
]

The client re-threw the error as an Error whose message is zod's issue list. With no catch, it became an uncaught page error, and the heading stayed at Notes (1). With the shipped try/catch, the same click gives alert: A note needs 1 to 200 characters. The status code is not a reliable signal for validation failures, so catch the thrown error at the call site.

Server functions are also same-origin RPC endpoints. A cross-site request forgery (CSRF) attack makes a visitor's browser send a request to your site from a page the attacker controls. Start's documentation says browser requests to server functions must come from the same origin, verified with the Sec-Fetch-Site, Origin or Referer headers. When the app does not define src/start.ts, Start installs its CSRF middleware automatically. The lab app has no such file, and curl against the addNote endpoint confirmed the rule:

POST with no Origin, Referer or Sec-Fetch-Site            -> HTTP/1.1 403, body: Forbidden
POST with 'Origin: https://evil.example'                  -> HTTP/1.1 403, body: Forbidden
POST with 'Origin: http://localhost:3100' and
  'Sec-Fetch-Site: same-origin', hand-written JSON body   -> HTTP/1.1 500, "Seroval Error (step: 3)"

The third case passed the origin check, then failed because the endpoint expects the framework's own serialized format. Call it through the function, not by hand. If you need an endpoint that outside callers can use, the documentation points you to server routes instead.

Common mistake: importing the server-only file into a route.

Symptom. A route imports readNotes from ../notes.server and its loader returns readNotes(). The production build fails with exit 1:

[import-protection] Import denied in client environment

  Denied by file pattern: **/*.server.*
  Importer: src/routes/index.tsx
  Import: "../notes.server"
  Resolved: src/notes.server.ts

  Trace:
    1. src/router.tsx:2:28 (entry) (import "./routeTree.gen")
    2. src/routeTree.gen.ts:2:44 (import "./routes/index")
    3. src/routes/index.tsx (import "../notes.server")

Cause. A route file is isomorphic, so its imports are part of the browser's dependency graph. Import protection is on by default. In the client environment it denies files matching **/*.server.*. In the build its behavior is error, which fails the build. In dev the default is mock, which warns and replaces the module with a mock. The lab's dev server shows why that matters: with the same bad import, vite dev answered GET / with status 200 and a correctly rendered <h1>Notes (<!-- -->0<!-- -->)</h1>, because the server half of the loader can read the file. The only sign was a log line, (client) warning: [import-protection] Import denied in client environment, followed by the same trace and a code frame pointing at return readNotes(). So the page you test first looks fine, and the production build is where the mistake stops you.

Fix. The tool's own suggestions are the right list. Call a createServerFn function from the route and import only the .functions.ts file. Use createServerOnlyFn for code that must throw if called from the browser. Use createIsomorphicFn when you need separate client and server implementations.

Putting it together

The notes app has one route file per page. Each file declares its loader and component, the server functions sit behind .functions.ts, and the file access sits behind .server.ts. Here it is as a production build, tied back to the three pains from the start.

vite build printed a client build and an SSR build. The client JavaScript is dist/client/assets/index-CqgCU_jZ.js at 344.48 kB, and the server entry is dist/server/server.js at 99.87 kB. The server entry is a fetch-style server entry: a module whose default export has a fetch(request) method that takes a web Request and returns a Response. The hosting guide's Node.js section tells Vite users to follow the Nitro deployment instructions, which is the route to take for a real Node.js deployment. This lab instead served the Vite build's server entry with srvx. This is the same shape the guide documents for Rsbuild output, where it says, "If your server entry is emitted as dist/server/server.js, use that path." Treat srvx as a quick local check. The command and its output were:

srvx --prod -s ../client -p 3100 dist/server/server.js
srvx 1.0.6 · node 26.8.2 · prod
◆ Server handler: ./dist/server/server.js
◇ Static files:   ./dist/client/
➜ Listening on: http://localhost:3100/ (all interfaces)

A direct import confirmed the shape: node -e import('./dist/server/server.js') -> exports: [ 'createServerEntry', 'default', 'n', 'r', 't' ] default keys: [ 'fetch' ].

The first pain was the empty shell. A request for /notes/1 returned <article><h1>Note <!-- -->1</h1><p>Buy coffee filters</p></article> in the server HTML. A request for /notes/99 returned HTTP 404 with No such note. in the body.

The second pain was keeping data access on the server. The client JavaScript had 0 matches for readFile, notes.json and node:fs. The only server-side traces in the browser's code are the /_serverFn/ stubs.

The third pain was drift between client and server. The route tree typed the links, and the validators guard the calls. In the full probe, the end of the run read:

[browser console] [index loader] running on the client
[browser request] GET /_serverFn/d1bfc0fb54611c74d719a3a71940f...
back home: Notes (1)
missing note: All notesNo such note.

The server log for the same run shows the split between server and browser (static asset requests and the long ids trimmed):

[index loader] running on the server
[5:03:51 PM] GET http://localhost:3100/ [200] (49.27ms)
[5:03:52 PM] POST http://localhost:3100/_serverFn/ef0700312129... [200] (4.38ms)
[5:03:52 PM] GET http://localhost:3100/_serverFn/d1bfc0fb5461... [200] (0.76ms)
[5:03:52 PM] GET http://localhost:3100/_serverFn/79a97f225afa...?payload=... [200] (1.02ms)
[5:03:52 PM] GET http://localhost:3100/_serverFn/d1bfc0fb5461... [200] (0.88ms)
[5:03:52 PM] GET http://localhost:3100/notes/99 [404] (2.07ms)

Read it in order. The server ran the index loader once, for the first page. The POST and the first list GET came from the browser after the Add click. The 79a97f... request is getNote, sent when the probe clicked the note's link; its arguments travel in the payload query string because the function uses GET. There is no GET /notes/1 document request at all: the browser ran the note route's loader itself and rendered the page from the returned note. The second list GET is the return to "All notes", and /notes/99 was a fresh full-page load, so the server rendered the not-found page with status 404. The two Failed to load resource ... 404 lines in the browser console were /favicon.ico and /notes/99, which is the not-found page doing its job.

Check yourself

  1. Which file produces the URL /notes/$id, and which file holds the route tree that types the links? *src/routes/notes.$id.tsx, because a . in a flat route file name denotes nesting and $id marks a path parameter. The generated src/routeTree.gen.ts holds the route tree.*
  1. During hydration of the server-rendered home page, why did the probe see no GET for the notes list? The server already ran the loader and put the data in the page, and hydration reused it. The first server-function request after load was the POST for the new note.
  1. A loader reads process.env.NOTES_ADMIN_KEY. Why does the server log say true and the browser console say false? *Route loaders are isomorphic, so the browser runs them too. In the client JavaScript, process.env.NOTES_ADMIN_KEY compiled to {}.NOTES_ADMIN_KEY, which is undefined. Read the variable inside a server function handler instead.*
  1. What does the browser's copy of addNote contain, and how does a validation failure reach the caller? *A small stub that calls /_serverFn/<id> with fetch, with no file-system code. The server answers with status 200 and a serialized error. The client re-throws it as an Error, which the call site must catch.*
  1. Why does a curl POST to a server function endpoint with no Origin, Referer or Sec-Fetch-Site header get HTTP 403? Server functions are same-origin RPC endpoints, and Start's automatic CSRF middleware rejects requests that carry none of those headers. Callers outside the app should use server routes.

Sources