Home

How TanStack Start runs your routes

What Start runs, and what it sends back

·33 min read
01Oct 3How TanStack Start runs your routesWhat Start runs, and what it sends back
02Oct 11TanStack Start and End-to-End Type SafetyWhat connects a server function to the code that calls it?
🔖 TL;DR
  • On a server-rendered first visit, Start sends HTML and the data used to render it, so browser React can run with those values.
  • Later navigation runs the route's loading and rendering code in the browser; code the browser has already downloaded is not downloaded again.
  • Server functions keep file or database operations on the server. Browser calls become HTTP requests; server-rendering calls stay in the server process.
  • Router keeps each route's loaded data in browser memory. When the user comes back to a page inside the running app, Router shows the stored data at once. By default it then runs the route's data-loading function again and replaces the data with the new result. Router can also run that function and download the page's code when the pointer rests on a link, before the click.

Our example is a small notes app. Its home page links to a Notes page, where you can read the saved notes and add another with the form. The server stores the list in a file; adding a note updates that file and refreshes the list on screen.

A notes page needs two things before it can show its list: the component that renders the notes, and the notes themselves. In a React app that fetches inside an effect, the browser gets them in sequence. It downloads the JavaScript, runs the component, then requests the data. Until that request finishes, the component can only show its loading state. Here is that page loading as a Vite React app:

Network
Name
Waterfall
localhost
index-DrA4Xy_C.js
index-DkMJ3Lci.css
/api/notes
4 requests

The HTML arrives with only an empty <div id="root"> in its body. The browser then requests the script, and only after the script arrives and runs does the component request /api/notes. The notes appear after that third request in a row returns.


TanStack Start can do the read and the render before sending the page to the browser. This is the same notes page in Start, loaded the same way:

Network
Name
Waterfall
notes
styles-DaGkLunp.css
index-BF4rFfr_.js
notes-CDjlxHyC.js
4 requests

The first response already contains the list as HTML, so the notes appear before any script has arrived, and no request asks for data. But the browser still needs a working React app: it must handle the form, follow links and render other pages without downloading a new document each time.


Start uses TanStack Router, a routing library for React. Router decides which page to show for a URL. We give it a route that connects /notes to the Notes component and a function that loads the notes. Following a link handled by Router changes the page inside the running app: Router selects the new URL's route, calls its data-loading function when needed, and renders its component.


TanStack Router can handle navigation between routes in a browser-only React app. Start uses the same route definitions to serve the first page from the server: it loads the notes, renders Notes to HTML, and sends the HTML and data to the browser. Start also provides server functions, which let browser code ask the server to do work such as reading the notes file.

A route connects loading to rendering

In our /notes route, loader is the function that obtains the data, and component is Notes, which renders the list. The loader calls getNotes, a server function declared with createServerFn around the readNotes file operation. Notes reads the loader's result with Route.useLoaderData():

tsx
// src/routes/notes.tsx
// ...other imports
import {
  readNotes,
} from '../notes.server'

const getNotes = createServerFn()
  .handler(() => readNotes())

export const Route =
  createFileRoute('/notes')({
    loader: () => getNotes(),
    component: Notes,
  })

function Notes() {
  const notes = Route.useLoaderData()
  return (
    <ul>
      {notes.map((note) => (
        <li key={note}>{note}</li>
      ))}
    </ul>
  )
}

createFileRoute('/notes') associates these operations with the /notes URL. Router calls the loader before rendering Notes, stores its result for the matched route, and makes that result available through Route.useLoaderData(). The component no longer has to mount before the app knows how to load its data.


The file operation is kept in notes.server.ts. Its readNotes function uses Node's file API to read notes.json and return an array:

json
["Read the Start docs", "Measure one request"]

getNotes keeps the file read behind a server function because a route loader is shared code. Start runs the loader on the server to prepare the page, and Router can run it in the browser on navigation. A server call to getNotes invokes the file operation in that process; a browser call sends a request to the server. The browser cannot read the server's filesystem itself.

One call, two implementations

Start's Vite plugin builds the app for both the server and the browser. During those builds, it transforms each createServerFn declaration. The server build retains the handler that calls readNotes. The browser build replaces that handler with a function that sends an HTTP request.


The replacement is often called a stub. Start's build assigns an ID to the server function and includes it in the browser stub. The stub sends its call to /_serverFn/<id>, where Start's server handler uses the ID to select and run the implementation. The handler serializes its result, and the stub reconstructs the value from the response. To the loader, await getNotes() still yields an array of notes.


For the initial page, Start sends HTML and the data used to render it. Browser React runs Notes with that data and reuses the list's existing DOM nodes. During this step, called hydration, React connects the event handlers needed for interaction.

Server handlerRenderingHydration
Server rendering
Browser
Server
Route loader
getNotes()
Notes → HTML
HTML + data
Hydrate Notes
Browser navigation
Browser
Server
DownloadNotes code
Route loader
getNotes()stub
HTTP request
getNotes()
notes
Notes → DOM

The server and browser implementations of getNotes() both return a promise, so the loader can await the notes in either place (the server-side call implementation). During server rendering, getNotes() runs inside the server process; in the browser, getNotes() sends an HTTP request.


In the browser build, Start removes the server-function handler. The remaining module no longer uses readNotes, so the bundler can remove that import and the file operations behind it. The server build retains those operations. The resulting Start output contains:

dist/
client/
assets/
index-BF4rFfr_.jsloader and getNotes stub
notes-CDjlxHyC.jsNotes component
styles-DaGkLunp.css
...
server/
server.jsrequest handler
assets/
notes.server-CznY1Pce.jsfile operations
notes-D5anmt4Y.jsserver functions
notes-D3VfP66w.jsNotes for server rendering
...

The browser's main bundle contains the loader and the getNotes stub, while Notes has a separate component chunk. Router can therefore request notes data without waiting for the component download. Start creates the stub with createClientRpc, replacing the handler that called readNotes. With the generated names expanded and the function ID shortened, the relevant browser code reads:

js
const getNotes =
  createServerFn().handler(
    createClientRpc(
      '<getNotes-id>',
    ),
  )

const Route =
  createFileRoute('/notes')({
    loader: () => getNotes(),
    // ...component loading
  })

On a later navigation, Router runs this loader in the browser. The call to getNotes() sends an HTTP request, and its promise resolves to the notes returned by the server. The loader's job is still to obtain the notes; the build changes which implementation its call reaches.


Under dist/server/, the build keeps the file operations and a server copy of Notes for rendering the first response. Start's output has no index.html: Start generates the page's HTML when its server handler receives the request.


Writing a note uses a server function too. An addNote server function can use method: 'POST', validate its input and append a note to the file. Its browser implementation sends that input to the server.

The first response carries HTML and data

When a browser requests GET /notes, Start creates a router for that page request and matches the URL. The matched route tells it to run our loader. getNotes() reaches the file inside the server process, and the resulting array becomes the route's loader data. React can now render Notes into HTML.


The response carries two forms of the notes inside its <body>. The <ul> is the rendered list, which the browser can display immediately. A <script data-tsr-stream-part=""> describes the matched routes and their loader results, which browser Router restores into its own state. In the document, the list sits inside <main> and the script follows it:

GET/notes
200OKtext/html
<!DOCTYPE html>
<html lang="en">
  <head>
    ...
  </head>
  <body>
    <main>
      <h1>Notes</h1>
      <ul>
        <li><span>•</span>Read the Start docs</li>
        <li><span>•</span>Measure one request</li>
      </ul>
      <form>
        ...
      </form>
    </main>
    ...
    <script data-tsr-stream-part="">
      ...
      $_TSR.router = ($R => $R[0] = {
        ...
        matches: $R[10] = [
          ...
          $R[12] = {
            ...
            l: $R[13] = ["Read the Start docs", "Measure one request"],
            ...
          }
        ]
      })($R["tsr"]);
      ...
    </script>
    <script type="module" async src="/assets/index-….js"></script>
    ...
  </body>
</html>

The script reconstructs $_TSR.router, including its matches array. Each match describes a route used for this page. The generated l field holds that route's loader result: the notes array that produced the list.


The list alone cannot supply Route.useLoaderData(): its items are the result of rendering, and Router does not recover arbitrary loader data by reading the DOM. Before Notes renders in the browser, Router restores the matching route's data from the transferred state. React can then hydrate the page: render the component against the existing server-generated DOM and attach the behavior needed for interaction.


Now Notes can run in the browser with the same note values that the server used. It does not need to call getNotes again just to obtain its initial data. The HTML lets the browser display the list; the serialized loader data lets React render that same list during hydration.


The server's router and the browser's router are separate instances. Their JavaScript objects are not shared. Start connects them by sending values that the browser can restore, along with references to the scripts needed to run the app. For the first page, the browser downloads the main bundle containing React and Router, the notes component's bundle, and the stylesheet. There is no separate browser request for the notes during this initial load.

After hydration, a Router <Link> can navigate to another route inside the running app. It changes the URL through browser history and asks the browser's router to load the destination. The current document stays in place.


Suppose the app is on / and has not loaded /notes yet. The main bundle already contains the route's loader and the getNotes request stub, but the Notes component lives in a separate JavaScript module. The build replaces component: Notes with code that can load that module. With generated names expanded and Vite's preload helper omitted, the route contains:

js
const importNotes = () =>
  import('./notes-CDjlxHyC.js')

const Route =
  createFileRoute('/notes')({
    loader: () => getNotes(),
    component:
      lazyRouteComponent(
        importNotes,
        'component', // export name
      ),
  })

lazyRouteComponent wraps importNotes so Router can load the component before rendering it. Calling importNotes runs the dynamic import(): the browser requests /assets/notes-CDjlxHyC.js and evaluates the downloaded JavaScript as a module. The second argument, 'component', selects that module's generated export for Notes.


Router can call importNotes to start the component download while the loader waits for getNotes(). The loader is already in the main bundle, so requesting data does not have to wait for the Notes module. If the request started only inside Notes, the browser would first have to download and run that component. Moving the request into the loader lets code and data load alongside each other.


One request supplies the function that renders the list; the other supplies the values that function will read:

ResourceRequest
Notes component codeGET /assets/notes-….js
Notes dataGET /_serverFn/<getNotes-id>

The server-function response is the handler's return value in Start's serialized form. The two notes appear as its "s" strings:

GET/_serverFn/…
200OKapplication/json
{"t":10,"i":0,"p":{"k":["result","error","context"],"v":[{"t":9,"i":1,"a":[{"t":1,"s":"Read the Start docs"},{"t":1,"s":"Measure one request"}],"o":0},{"t":2,"s":1},{"t":11,"i":2,"p":{"k":[],"v":[]},"o":0}]},"o":0}

The request stub rebuilds the notes array, ["Read the Start docs", "Measure one request"], from this body and resolves the loader's getNotes() call with it.


Router renders the /notes match once the Notes module has loaded and the loader has returned the notes array. Browser React runs Notes, whose Route.useLoaderData() reads that array from the match's loader data. Its compiled JSX produces React elements describing the <ul> and its <li> children.


React's DOM renderer, already loaded with the app, uses those elements to create the list's DOM nodes and insert them into the current page. The downloaded module supplies Notes and its rendering logic; the server-function response supplies the notes. Neither response contains a new HTML document. This navigation renders into the running app, so it does not hydrate a new server-rendered page.

Router keeps each route's loader result in the browser

After a navigation to /notes, the browser holds two things for the route: the Notes code and the notes array. The code is the module notes-CDjlxHyC.js, which the browser downloaded for /notes. The app's code does not change until the page is reloaded, so the browser keeps the loaded module and Router keeps the Notes component it exports. Coming back to /notes therefore never requests the module again, and nothing checks how old it is. The notes array is different: it is the loader's result, and the server can return a different array the next time it reads notes.json. Router keeps that array in browser memory too, in its own store called Router's cache, and checks its age to decide whether to request it again.


Router's cache holds one entry per route for each set of path parameter values: /notes has no parameters and one entry, while a route for /notes/$noteId would have one entry for each note opened. After the navigation above, the /notes entry holds the array from the getNotes response. When /notes is the first page instead, Router fills the entry from the loader data that came with the server's HTML.


When the user comes back to /notes, Router checks how old the entry is. A route's staleTime sets how long an entry counts as fresh. The default is zero, so the stored notes are already stale by then. Router renders the stored notes at once, runs the loader in the background, and replaces the notes on screen when the new array arrives. With a staleTime above zero, coming back within that time shows the stored notes and sends no request instead. Here is the user coming back to /notes after a note was added on the server while another page was showing:

RenderingServer handler
Returning to /notes
Browser
Server
Stored notes on screen
getNotes()
New notes on screen
request from the loader
new notes

To save a note, the browser calls the addNote server function and waits for its POST response. Saving is not a navigation, so Router does not run the loader by itself. In Notes, useRouter() returns the running router instance, and the submit handler calls router.invalidate() on it after the save. Router marks every stored loader result as invalid, so the next time each route is shown, its loader runs even within its staleTime. For /notes, which is on screen, that happens at once: Router runs its loader in the background, and the list keeps the old notes until the updated array arrives.


Router's cache is part of the router object, so it lasts until the browser loads a new document. Following links inside the app keeps it. A reload creates a new router with an empty cache, and Router fills only the entries for the new page from that page's HTML. When a stored result's route is no longer on screen and five minutes have passed since it was loaded, Router removes it at the next navigation. The route option gcTime sets those five minutes.

Router can load a route before the click

Following the link from / to /notes made the browser send two requests: one for the Notes module and one for the loader's getNotes() call. Both depend only on where the link leads, not on the click, so Router can send them earlier, while the pointer is still on the link. This is preloading: Router downloads the destination's component code and runs its loader, but leaves the URL and the screen as they are.


Preloading is off unless the router or the link turns it on. A Start app creates its router in src/router.tsx, and when you create an app with Start's CLI, that file already turns preloading on for every link with defaultPreload:

tsx
export function getRouter() {
  const router = createTanStackRouter({
    routeTree,
    // ...
    defaultPreload: 'intent',
    defaultPreloadStaleTime: 0,
  })

  return router
}

defaultPreload sets when a <Link> starts its preload (TanStack Router's preloading guide):

ValueThe link preloads
false (Router's default)never; the click starts the module and data requests
'intent'when the pointer rests on it or it receives focus, or at once on touch
'viewport'when it comes within 100px of the viewport
'render'as soon as it renders

With 'intent', a hover or focus has to last 50 milliseconds before the preload starts; a touch starts it at once. The router's defaultPreloadDelay sets this wait, 50 milliseconds by default. 'viewport' waits the same time after the link comes near the viewport. If the pointer leaves sooner, nothing is requested, so moving the mouse across a page of links does not fetch every page (TanStack Router's preloading guide on the delay). A single <Link> can replace the router's setting with its own preload prop and the wait with its preloadDelay prop.


With 'intent', hovering over the link on / sends the same two requests a click would, one for the Notes module and one for getNotes, which appears as the _serverFn/… row:

Network
Name
Waterfall
notes-CDjlxHyC.js
_serverFn/46cce3fa…
2 requests

Clicking the link afterwards sends only the getNotes request:

Network
Name
Waterfall
_serverFn/46cce3fa…
1 request

After the hover, the browser keeps the loaded Notes module, so the click does not request it again. Router keeps the notes array in its cache as a preloaded entry, and on the click it checks how old that entry is. How long a preloaded entry stays fresh is the router's defaultPreloadStaleTime, and the src/router.tsx above sets it to 0. The entry is therefore already stale when the user clicks, so the click calls getNotes again. Router renders the preloaded notes at once and runs the loader again in the background, as it does when the user comes back to /notes and the stored entry is stale.


Without the defaultPreloadStaleTime: 0 line in src/router.tsx, the setting is Router's own default of 30 seconds. In that case, a click less than 30 seconds after the preload finished renders the preloaded notes and sends no request. A click while the preload's request is still running waits for that request instead of sending a second one, whether defaultPreloadStaleTime is 0 or 30 seconds. A preloaded entry that no click uses is removed at the first navigation five minutes or more after it was loaded. defaultPreloadGcTime sets those five minutes.

Is it just a better Pages Router?

Start and the Next.js Pages Router can produce the same first page: HTML for the list, its notes data, and JavaScript that lets the browser keep running Notes. The important difference is where our data-loading code runs when we navigate to another page.


In the Pages Router, we can put the file read in getServerSideProps. Next calls this function on the server and passes its result to the page component as props:

tsx
// pages/notes.tsx
export async function getServerSideProps() {
  const notes = await readNotes()
  return { props: { notes } }
}

export default function Notes({ notes }) {
  return (
    <ul>
      {notes.map((note) => (
        <li key={note}>{note}</li>
      ))}
    </ul>
  )
}

The functions in this file have different destinations. getServerSideProps and its file read stay on the server. Notes is included in both builds: the server runs it to produce the first HTML, and the browser runs it to hydrate the page and render later navigations. For the first visit, Next includes the props in the HTML document's __NEXT_DATA__ script.


Now start on the home page and click a Next <Link> to /notes. The browser downloads the Notes component's JavaScript and asks Next for its props. The data request makes Next run getServerSideProps on the server:

GET/_next/data/<build-id>/notes.json
200OKapplication/json
{"pageProps":{"notes":["Read the Start docs","Measure one request"]},"__N_SSP":true}

The __N_SSP flag tells Next's client router that these props came from getServerSideProps. The browser passes pageProps.notes into Notes, whose map creates the list elements. React updates the DOM from those elements. Next's client router makes the data request, but our getServerSideProps function executes entirely on the server (the Pages Router data-fetching contract).


Start puts our data-loading function in the browser as well. To see which parts run there, give the original loader a filter that keeps notes beginning with Read. The comments show where the code executes on client navigation:

tsx
const getNotes = createServerFn()
  .handler(() => {
    // Server: read the file
    return readNotes()
  })

export const Route =
  createFileRoute('/notes')({
    loader: async () => {
      // Browser: before request
      const prefix = 'Read'
      const notes =
        await getNotes()

      // Browser: after response
      return notes.filter(
        (note) =>
          note.startsWith(prefix)
      )
    },
    component: Notes,
  })

The browser sets prefix to 'Read', then calls the getNotes request stub. While the loader waits at await, the server handler reads the file and returns the full notes array. When the response arrives, the browser continues after await and filters the returned array. The loader's result is now ["Read the Start docs"], which Notes reads through Route.useLoaderData().


The server boundary is in a different place. On client navigation, the entire getServerSideProps function executes on the server; Start's loader executes in the browser and calls getNotes for the file read. If we put the same prefix assignment and filtering inside getServerSideProps, they would run on the server before Next returned the page props. On a direct visit to the Start page, the loader runs on the server too; the browser labels above describe its work during client navigation.


Router's cache is in the browser as well. If a route's staleTime is longer than the time since its result was loaded, Router does not run the loader on a return visit, and the browser sends no request for the notes. With getServerSideProps, each client navigation to the page sends a /_next/data request, and any cache check inside the function runs on the server, where it cannot see what the browser already holds.

A layout can load its own data

Suppose we group notes into notebooks and add a notebook sidebar beside the selected note. We now want the sidebar to keep its data when the selected note changes. Opening /notes/first needs the notebook list and the first note; navigating to /notes/second changes the selected note while the sidebar stays on screen.


With the Pages Router, both URLs use pages/notes/[noteId].tsx; [noteId] matches the selected note's ID. If we load both values through getServerSideProps, that page's function has to collect the notebook list and the selected note, then return both as props. Switching to /notes/second requests another set of page props, so the function supplies both values again. We can keep a shared layout mounted, but that layout cannot export a separate getServerSideProps of its own (Pages Router layouts).


In Start, we can change /notes into a parent route with its own loader. The parent renders the sidebar and an <Outlet />, where the selected child route appears. A child /notes/$noteId route uses $noteId for the selected note's ID and has its own loader and component:

RouteIts data and component
/notes (parent)NotesLayout reads the notebook list and renders the sidebar
/notes/$noteId (child)Note reads the selected note and renders inside the outlet

Each component reads its route's result with Route.useLoaderData(). Both routes match /notes/first, so Router prepares both results. On navigation to /notes/second, the child has a new noteId, while the parent still matches /notes. Here the parent loader reads the same notebook list for either selected note. Unless we ask Router to reload the parent, it keeps that result while the child's loader reads the second note.


The notebook list therefore belongs to the parent route instead of being part of every selected-note page's data function. Router tracks the parent and child results separately. If the notebook list changes, the app can invalidate the parent's data to run its loader again (Router's loading lifecycle).

A server function can also serve a form

The form should be able to save a note while staying on the current page. getServerSideProps supplies page props, so a Pages Router form needs another entry point to write the note. Defining a POST handler in pages/api/notes.ts creates an /api/notes endpoint. The submit handler calls it with fetch; the API handler reads the request, writes the note and returns a response.


In Start, the submit handler can call await addNote({ data: { text } }). createServerFn supplies the browser stub and the server handler for that call, just as it does for getNotes, so the write needs no separate API route. Server functions can be called from a route's loader and from event handlers alike; they are not tied to producing a page's props.

App Router can keep the list component on the server

The Pages Router and Start examples both send notes data to a browser copy of Notes. That component executes notes.map(...) in the browser to create the list elements on navigation. Next's App Router, an alternative to the Pages Router, can keep Notes out of the browser entirely. A Server Component is a component that runs only on the server. Written as one, Notes can be an async function that reads the notes and turns them into list elements.

tsx
// app/notes/page.tsx
export default async function Notes() {
  const notes = await readNotes()
  return (
    <main>
      <h1>Notes</h1>
      <ul id="notes">
        {notes.map((note) => (
          <li key={note}>{note}</li>
        ))}
      </ul>
    </main>
  )
}

In App Router, pages and layouts are Server Components by default. In this App Router page, both readNotes() and notes.map(...) execute on the server, and the browser never downloads the Notes function. Notes can read the notes file without getServerSideProps or a server function because it already runs on the server, where the file is.


On navigation, App Router sends the result of running Notes instead of a notes array. That result is the React Server Component (RSC) payload, which describes the elements Notes returned. When you click a link to /notes, Next's client router requests the payload with a ?_rsc=… request unless the browser already has it cached. The server runs Notes to answer that request. The payload's line labelled 4 holds the elements Notes returned, with the two list items inside the ul:

GET/notes?_rsc=…
200OKtext/x-component
...
4:["$","main",null,{"children":[["$","h1",null,{"children":"Notes"}],["$","ul",null,{"id":"notes","children":[["$","li","Read the Start docs",{"children":"Read the Start docs"}],["$","li","Measure one request",{"children":"Measure one request"}]]}],...]}]

The li and its children tell browser React which element and text to render, and React updates the DOM from that description. Pages Router, Start and App Router split the work of a client navigation like this:

VersionWork on client navigation
Pages RouterServer: getServerSideProps returns notes as page props.
Browser: Next requests the props; Notes creates list elements.
StartServer: getNotes returns the notes array.
Browser: Router runs our loader; Notes creates list elements from its data.
App RouterServer: Notes reads notes and creates list elements.
Browser: Next requests the RSC payload; React updates the DOM from it.

Suppose we add a click counter beside the list. It needs state and a click handler in the browser. In Start and the Pages Router, Notes already runs in the browser, so the counter could live inside it. In App Router, Notes runs only on the server, so the counter becomes its own component, Counter, which keeps the count with useState. Its module starts with the 'use client' directive, which adds the module to the browser build. A component from such a module is a Client Component:

tsx
'use client'

export default function Counter() {
  // ...useState and a button's onClick handler
}

The server Notes imports Counter and returns a <Counter /> element after the list:

tsx
// app/notes/page.tsx
import Counter from './counter'
// ...
    <main>
      <h1>Notes</h1>
      <ul id="notes">{/* ...the notes */}</ul>
      <Counter />
    </main>

The RSC payload does not describe the elements Counter returns; it refers to Counter's module:

GET/notes?_rsc=…
200OKtext/x-component
...
d:I[3498,["568","static/chunks/app/notes/page-….js"],"default"]
4:["$","main",null,{"children":[["$","h1",null,{"children":"Notes"}],["$","ul",null,{"id":"notes","children":[...]}],["$","$Ld",null,{}]]}]

The payload stores the module reference as a separate line labelled d. That line names the JavaScript chunk containing Counter's module, and the module's default export, which is Counter. In the line labelled 4, the entry ["$","$Ld",null,{}] follows the ul. It marks where Counter goes in the element tree: $Ld refers to the line labelled d, and {} is the props object. The browser downloads the chunk and runs Counter, while Notes stays on the server.

How much of the first render happens on the server

Back in Start, a route's component may need a browser API, such as window, while it renders. The server has no such API, so it cannot render that component, although the route's loader can still read the notes there. Start can run the loader on the server and leave the component to render in the browser. The value of the route's ssr option decides which of those two steps, loading and rendering, the server performs for the first page request. For a /notes route whose loader returns getNotes(), the values true, 'data-only' and false decide which side does each step of the first page request:

Server handlerRenderingHydration

With 'data-only' or false, the initial HTML contains no list for this route. Router can show a route's pendingComponent in its place; without one, that area stays empty until browser React renders the route. With 'data-only', the response still carries the notes, and the browser can render the list without another read.


With ssr: false, the server does not run the notes route's loader. The browser runs it during startup, calling getNotes() through the request stub. That adds a server-function request before the list can render. The file read still happens on the server.


These options govern the initial server work. On client navigation, a loader that needs to run executes in the browser regardless of whether its route uses ssr: true.

Streaming carries a pending value through the response

Start can begin sending a server-rendered page before every part of it is ready. Suppose the notes are ready, but a slower syncStatus() server function is still working. Its promise will resolve to "Synced · 2 notes". The server can send the list and the fallback text Syncing… in the initial HTML response, then append the completed status to that response.


The loader awaits the notes and returns the status promise alongside them:

tsx
loader: async () => ({
  notes: await getNotes(),
  sync: syncStatus(),
})

Returning this object does not wait for every promise inside it. The loader finishes with a notes array and a still-pending sync promise. The component reads both from Route.useLoaderData(), renders the list, and uses Await for the status:

tsx
<Await promise={sync} fallback="Syncing…">
  {(status) => <span>{status}</span>}
</Await>

Await lets React render Syncing… while that part of the page is waiting. The first HTML can therefore contain the list and the fallback. But the browser also needs loader data with a promise that its own Await can read. A server Promise is a live JavaScript object; it cannot move into the browser's JavaScript process.


Start's serializer generates code to create another promise there. In the response's route-state script, l now holds the loader's { notes, sync } object. This is the part that reconstructs it:

GET/notes
200OKtext/html
<!DOCTYPE html>
<html lang="en">
  ...
  <body>
    <main>
      ...
    </main>
    <script data-tsr-stream-part="">
      ...
      l: $R[13] = {
        notes: $R[14] = ["Read the Start docs", "Measure one request"],
        sync: $R[15] = ($R[16] = ($R[17] = () => {
          const resolver = { p: 0, s: 0, f: 0 };
          resolver.p = new Promise((resolve, reject) => {
            resolver.s = resolve;
            resolver.f = reject;
          });
          return resolver;
        })()).p
      }
      ...
    </script>
    ...

When this script runs in the browser, new Promise creates a pending promise. The resolver object keeps that promise in p and its resolve function in s. The surrounding assignments save the object at $R[16] and put its promise in l.sync. Router restores that sync value as loader data. Creating it does not call syncStatus() again; it waits for the server's result to arrive.


The initial response remains open. When the server's syncStatus() finishes, Start's serializer appends a script carrying the result and a reference to the saved resolver:

GET/notes
200OKtext/html
Later in the same response
    ...
    <script>
      ($R => ($R[18] = (resolver, data) => {
        resolver.s(data);
        ...
      })($R[16], "Synced · 2 notes"))($R["tsr"]);
      ...
    </script>
    ...
  </body>
</html>

$R[16] is the saved resolver for l.sync, the browser's pending promise. Its s property holds that promise's resolve callback. Calling s with "Synced · 2 notes" fulfills the promise, giving Await the status without another request (the route-state stream).


The promise-resolution script gives browser React the status value. On the server, React also renders the finished status to HTML. It appends that HTML and an instruction to replace Syncing… in the same response. The original HTML marks the fallback's location with B:0; the finished HTML arrives in S:0:

html
<!-- Initial HTML: the Await fallback beside the notes list -->
<!--$?--><template id="B:0"></template>Syncing…<!--/$-->

<!-- Later in the same response -->
<div hidden id="S:0"><span>Synced · 2 notes</span></div>
<script>
  // ...React's replacement helper
  $RC("B:0", "S:0")
</script>

$RC("B:0", "S:0") tells React's replacement helper to put the new <span> where the fallback was. The IDs connect the waiting location and completed HTML inside this response; the application does not choose them.


Awaiting syncStatus() before returning from the loader would make the route wait for the status before rendering. Returning its promise lets the notes appear first while Start and React send the remaining data and HTML when they are ready.

One route definition, run in two places

In a React app that fetches inside an effect, the browser downloads Notes, runs it, and only then asks for the notes, so the list waits for the code and then for the data. Start avoids that wait by running the same route in two places. For a page request with the default ssr: true, the server runs the loader and renders Notes, so the first response carries the list as HTML along with the data that browser React uses for hydration. When a link leads to a route whose code the browser has not loaded yet, the loader still runs at once, because it is in the main bundle. Its getNotes() request starts while the Notes code downloads.


A Start route's loader and component therefore run in both places: on the server for a page request, unless the route's ssr option says otherwise, and in the browser after that. Neither can read the server's files directly, because the same code also runs in the browser, and neither can use a browser API such as window while the server renders the page. What must stay on the server, such as reading the notes file, goes in a server function; in the browser, calling that function sends a request that runs it on the server. Next.js draws the line elsewhere. getServerSideProps runs only on the server and returns props that Next sends to the browser, and an App Router page keeps Notes on the server and sends only Client Components such as Counter to the browser.