Skip to content

Engineering

TanStack Start loader errors: why the SSR stack points at your HTML page

A route loader that throws during server rendering reaches the browser as a message and nothing else. We traced one error through three setups and counted what an error tracker received each time.

Andrew Babo5 min read

Share
On this page
TanStack Start loader errors: why the SSR stack points at your HTML page

A TanStack Start route loader that throws during server rendering reaches the browser as its message and nothing else. If your error tracker only runs in the browser, it reports that rebuilt error, and the stack points at your HTML page instead of the loader. If you also report on the server, you can get the same failure twice.

The fix is to report on the server, where the real stack exists, and tell the browser that error is already taken care of. We traced one failing loader through three setups on 26 October 2026 and counted every event.

Why the stack disappears#

On the first page load, TanStack Start runs the route loaders on the server, renders the HTML and sends the router's state along with it, errors included. The browser picks that state up and shows the route's error component.

Errors do not cross that line whole. The router's serializer has a plugin for them, and its source says what it keeps: “this plugin serializes only the message part of an Error”. The comment gives the reason, errors with functions attached that cannot be serialized, and it is a sensible trade. On the other side it becomes new Error(message), built by a script inside the page.

So the browser holds an error with the right message, a fresh stack, and no trace of the loader.

How an SSR loader error reaches the browser
router-core 1.171.34 · serializer read on 26 Oct 2026

The lab#

One small TanStack Start app, built for production with @tanstack/react-start 1.168.60 and @tanstack/router-core 1.171.34. Its /orders route has a loader that always throws:

TSX
function loadOrders(): { count: number } {  throw new Error("orders loader failed on the " + (typeof window === "undefined" ? "server" : "browser"));}export const Route = createFileRoute("/orders")({ loader: () => loadOrders(), component: Orders });

We opened /orders directly, so the loader ran during server rendering, and we navigated to it from /, so it ran in the browser. Three runs each, in Chromium, with a local collector recording every event: where it came from, how it was captured and its top frames. We used our own SDK, @belvador/tanstack-start, in three setups.

What arrived#

Events per failing page in three setups
Lab 26 Oct 2026 · production build · 3 runs per setup, the same result each run
SetupEvents per SSR failureReported fromStack
Browser only (SDK 0.5.0)1browser2 frames, both the /orders HTML page
Browser and server, no handoff2server, then browserduplicate
Browser and server, with handoff (0.5.1)1serverloadOrders and loader in the server bundle

Browser only. Every run reported the error once, which is good: nothing was lost. But both frames were /orders itself, the page that rebuilt the error. A reader of that issue learns the message and the URL, and has to find the loader on their own. After a client navigation the same setup did better, because the loader ran in the browser and the stack showed loader in the bundle.

Both sides, no handoff. Reporting from the route's onError on the server gave the right stack, and then the browser reported the rebuilt copy too: two events for one failure, in all three runs. A tracker that marks an error as "already sent" cannot recognize the copy, because that mark lives on the original error object, which never left the server.

Both sides, with a handoff. The server reports the error, then adds a short hash of its message to the state it sends with the page. The browser skips one error whose message matches that hash. One event per failing page, from the server, with the loader's frames. With 40 concurrent requests, half to /orders and half to /, all 20 failing pages carried a hash, none of the 20 home pages did, and the collector received 20 server events.

How to set this up, with any tracker#

TanStack Router gives you two hooks, and they answer different questions. onError is “called when an error occurs during the route loading”; that is the place to report on the server, where it still has the original error. onCatch and defaultOnCatch run when an error boundary catches it, which in the browser includes the rebuilt SSR copy.

  1. Report loader errors on the server, from onError or the server half of your SDK, so the stack is the real one.
  2. Decide how the browser avoids the copy. Either skip errors the server already reported, as above, or accept two events and group them. Do not drop all SSR errors in the browser: an app without server reporting would then lose them.
  3. Make sure an error component exists, on the route or through defaultErrorComponent, as TanStack Start's error boundary guide shows. defaultOnCatch does not run for the router's outermost fallback.
  4. Test it once in a production build: throw from a loader, load the page directly, then navigate to it, and count the events. Test the build you ship, not only the dev server.

Where Belvador fits#

@belvador/tanstack-start 0.5.1 does the handoff above: withBelvadorRouter wraps your router options and the server half reports loader errors with the server stack, then the browser skips that one error. Without the server half, the browser still reports it once. The hash never contains the message, and your own dehydrate and hydrate keep working. The TanStack Start guide has the three places to wire it, and the TanStack Start page shows the rest of the setup. Lovable apps are TanStack Start apps, so the blank page post covers the other way these apps fail on first load.

Keep reading#

Part of the guide: JavaScript error handling for AI-generated apps: where errors escape

All posts

Read next in the docs

See your own production errors this way

Belvador runs on your own Hetzner server: errors, cron jobs, uptime and traces, with your data staying yours.