Which project does this relate to?
Start
Describe the bug
In SPA mode, the prerendered shell throws Minified React error #418 ("Hydration failed because the server rendered HTML didn't match the client") whenever the browser can render route content on its first pass. React then discards the server HTML and re-renders the whole document.
The shell leaves the route area empty inside a resolved suspense boundary:
<div id="app"><!--$--><!--$--><!--/$--><!--/$--></div>
Hydration therefore only lines up when the client's first pass also renders nothing, which happens by accident whenever the matched route's chunk has not loaded yet. It does not line up when a component is already available synchronously, and two ordinary cases make it available:
- an address that matches no route -
notFoundComponent is declared on the root route, so it sits in the main bundle and renders immediately;
- the address the shell was prerendered for - its route chunk is in the shell's
modulepreload list.
This only shows up in the built output served the way a static host serves a SPA (unknown addresses answered with the shell). vite dev never shows it, because it renders a shell per request. vite preview never shows it either, because it answers an unknown address with a 404 document instead of the shell.
Your Example Website or App
This repository's own e2e/react-start/spa-mode app reproduces it, no changes needed.
Steps to Reproduce the Bug or Issue
Using this repo at ac223be:
pnpm install
pnpm nx run @tanstack/react-start:build
pnpm nx run @tanstack/react-router-devtools:build
cd e2e/react-start/spa-mode && pnpm exec vite build
Then serve dist/client the way a static host serves a SPA: an existing file wins, a directory's index.html wins, anything else falls back to /index.html with a 200 (nginx try_files $uri $uri/index.html /index.html). Open each address and watch the console:
| Address |
Served |
Console |
/ |
the shell |
React #418 |
/posts/1 |
its own prerendered page |
clean |
/unknown |
the shell (SPA fallback) |
React #418 |
npx serve dist/client, which package.json currently uses for the e2e run, answers /unknown with its own 404 page, so the suite never loads the shell at an address it was not prerendered for. / is loaded, and it throws, but nothing in the suite listens for uncaught errors.
A minimal app outside this repo, on the published @tanstack/react-router@1.170.36 / @tanstack/react-start@1.168.53 with React 19.3.0, behaves the same:
vite.config.ts
import {defineConfig} from 'vite'
import {tanstackStart} from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
tanstackStart({spa: {enabled: true, prerender: {outputPath: '/index.html'}}}),
viteReact(),
],
})
src/routes/__root.tsx
import {HeadContent, Outlet, Scripts, createRootRoute} from '@tanstack/react-router'
import type {ReactNode} from 'react'
export const Route = createRootRoute({
ssr: false,
shellComponent: RootDocument,
component: RootComponent,
notFoundComponent: NotFound,
})
function RootDocument({children}: {children: ReactNode}) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<div id="app">{children}</div>
<Scripts />
</body>
</html>
)
}
function RootComponent() {
return <Outlet />
}
function NotFound() {
return <h1>Not found</h1>
}
with src/routes/index.tsx and src/routes/about.tsx rendering an <h1>. Built and served with the same fallback rule: / throws #418 (its chunk is preloaded by the shell), /no-such-page throws #418 (the not-found component is in the main bundle), /about is clean (its chunk has not loaded on the first pass). Replacing notFoundComponent with a splat route src/routes/$.tsx that renders the same <h1> makes /no-such-page clean, because the page then arrives in its own chunk like any other route.
Expected behavior
Hydrating the shell at any address is clean, and the client fills the empty route area as an ordinary update.
Screenshots or Videos
No response
Platform
- OS: macOS 15
- Browser: Chromium (Playwright 1.5x)
- Version:
@tanstack/react-router 1.170.36 and this repo at ac223be; React 19.3.0 / 19.2.3; Vite 8
Additional context
What I could establish while tracking this down, in case it saves someone time:
- It is not the not-found page's content. With
notFoundComponent stubbed to return null the error still fires, so what differs is the shape of the tree, not what is drawn in it.
- Deferring that component does not help either: neither
lazyRouteComponent(() => import('./NotFound')) nor wrapping it in the router's own ClientOnly changes anything.
- In shell mode
resolveSsr (packages/router-core/src/load-server.ts) returns route.id === rootRouteId, so the root match is dehydrated with ssr: true and it is the only match dehydrated (packages/router-core/src/ssr/ssr-server.ts). On the client nothing then marks the subtree below the root as "not rendered on the server", so MatchView takes the plain path rather than the ClientOnly path that produced the empty HTML.
- I tried carrying an
isShell flag in the dehydrated payload and setting ssr: false on the client for the matches below the dehydrated prefix. It did not fix it, which I read as the mismatch being the boundary structure itself rather than the ssr flag: the shell renders no child match at all (only the root is in router.stores.matches, so the root's <Outlet /> returns null), while the hydrating client renders <Suspense> plus a child Match. I did not take that further because the hydration path is intricate enough that a guess seemed likely to cost you more time than it saves.
Workaround for anyone hitting the not-found half of this: a catch-all route (src/routes/$.tsx) rendering the same page, so an unknown address matches an ordinary code-split route.
I have a PR adding the missing e2e coverage for this (serving the built app with a SPA fallback, and asserting nothing uncaught on load) that I can link once it is up.
Which project does this relate to?
Start
Describe the bug
In SPA mode, the prerendered shell throws
Minified React error #418("Hydration failed because the server rendered HTML didn't match the client") whenever the browser can render route content on its first pass. React then discards the server HTML and re-renders the whole document.The shell leaves the route area empty inside a resolved suspense boundary:
Hydration therefore only lines up when the client's first pass also renders nothing, which happens by accident whenever the matched route's chunk has not loaded yet. It does not line up when a component is already available synchronously, and two ordinary cases make it available:
notFoundComponentis declared on the root route, so it sits in the main bundle and renders immediately;modulepreloadlist.This only shows up in the built output served the way a static host serves a SPA (unknown addresses answered with the shell).
vite devnever shows it, because it renders a shell per request.vite previewnever shows it either, because it answers an unknown address with a 404 document instead of the shell.Your Example Website or App
This repository's own
e2e/react-start/spa-modeapp reproduces it, no changes needed.Steps to Reproduce the Bug or Issue
Using this repo at
ac223be:Then serve
dist/clientthe way a static host serves a SPA: an existing file wins, a directory'sindex.htmlwins, anything else falls back to/index.htmlwith a 200 (nginxtry_files $uri $uri/index.html /index.html). Open each address and watch the console://posts/1/unknownnpx serve dist/client, whichpackage.jsoncurrently uses for the e2e run, answers/unknownwith its own 404 page, so the suite never loads the shell at an address it was not prerendered for./is loaded, and it throws, but nothing in the suite listens for uncaught errors.A minimal app outside this repo, on the published
@tanstack/react-router@1.170.36/@tanstack/react-start@1.168.53with React 19.3.0, behaves the same:vite.config.tssrc/routes/__root.tsxwith
src/routes/index.tsxandsrc/routes/about.tsxrendering an<h1>. Built and served with the same fallback rule:/throws #418 (its chunk is preloaded by the shell),/no-such-pagethrows #418 (the not-found component is in the main bundle),/aboutis clean (its chunk has not loaded on the first pass). ReplacingnotFoundComponentwith a splat routesrc/routes/$.tsxthat renders the same<h1>makes/no-such-pageclean, because the page then arrives in its own chunk like any other route.Expected behavior
Hydrating the shell at any address is clean, and the client fills the empty route area as an ordinary update.
Screenshots or Videos
No response
Platform
@tanstack/react-router1.170.36 and this repo atac223be; React 19.3.0 / 19.2.3; Vite 8Additional context
What I could establish while tracking this down, in case it saves someone time:
notFoundComponentstubbed toreturn nullthe error still fires, so what differs is the shape of the tree, not what is drawn in it.lazyRouteComponent(() => import('./NotFound'))nor wrapping it in the router's ownClientOnlychanges anything.resolveSsr(packages/router-core/src/load-server.ts) returnsroute.id === rootRouteId, so the root match is dehydrated withssr: trueand it is the only match dehydrated (packages/router-core/src/ssr/ssr-server.ts). On the client nothing then marks the subtree below the root as "not rendered on the server", soMatchViewtakes the plain path rather than theClientOnlypath that produced the empty HTML.isShellflag in the dehydrated payload and settingssr: falseon the client for the matches below the dehydrated prefix. It did not fix it, which I read as the mismatch being the boundary structure itself rather than the ssr flag: the shell renders no child match at all (only the root is inrouter.stores.matches, so the root's<Outlet />returnsnull), while the hydrating client renders<Suspense>plus a childMatch. I did not take that further because the hydration path is intricate enough that a guess seemed likely to cost you more time than it saves.Workaround for anyone hitting the not-found half of this: a catch-all route (
src/routes/$.tsx) rendering the same page, so an unknown address matches an ordinary code-split route.I have a PR adding the missing e2e coverage for this (serving the built app with a SPA fallback, and asserting nothing uncaught on load) that I can link once it is up.