TanStack Router 15 ⚛️ Server-Side Rendering — SSR & hydration
The previous chapters covered the client-side route handling, the loaders, the actions, and the testing. This chapter covers the server-side rendering (SSR) and the hydration: the mechanism that renders the application on the server for the initial request, sends the HTML to the browser, and then “hydrates” the static HTML with the interactive React application. The LFCS exam does not test TanStack Router, but the front-end engineering certification paths do. The critical distinction is between the SSR (the server renders the HTML) and the CSR (the client renders the HTML). The SSR provides the faster First Contentful Paint, the better SEO, and the social media preview. The CSR provides the faster Time to Interactive after the initial load. The TanStack Router supports both, and the SSR integration is the primary path for the production applications that need the SEO.
Key point: The SSR requires the router to be created on the server, the loaders to run on the server, and the data to be dehydrated into the HTML. The client then hydrates the application with the dehydrated data, which avoids the duplicate fetch. The @tanstack/react-router-ssr-query package provides the setupRouterSsrQueryIntegration for the TanStack Query integration. The createMemoryHistory is for the test, the createBrowserHistory is for the client, and the createMemoryHistory with the initial URL is for the server. The router.dehydrate() is the state export; the router.hydrate() is the state import.
Why SSR and hydration require a specific mental model
The double-render problem. The SSR renders the application on the server. The result is the HTML with the content. The browser receives the HTML and paints it. The JavaScript then loads and renders the application on the client. If the client renders the different content than the server, the React hydration mismatch occurs. The hydration is the reconciliation of the server HTML with the client React tree. The mismatch is the source of the visual glitches and the warnings.
The loader problem. The loader runs on the server, fetches the data, and returns the content. The same loader runs on the client if the data is not dehydrated. The dehydration is the mechanism that transfers the server-fetched data to the client, so the client does not re-fetch. The router.dehydrate() is the export; the router.hydrate() is the import.
The history problem. The server does not have the browser URL. The router needs the createMemoryHistory with the initialEntries: [request.url]. The request.url is the full URL from the HTTP request. The router matches the routes and runs the loaders. The client uses the createBrowserHistory, which reads the window.location.
The request context problem. The server handles multiple requests simultaneously. The shared state between the requests is the data leak. The router and the query client must be created per request, not at the module scope. The createRouter() inside the request handler is the correct pattern. The module-scope router is the source of the cross-request data contamination.
The streaming problem. The TanStack Start (the official SSR framework) supports the streaming SSR. The server sends the HTML in chunks as the loaders resolve. The client hydrates each chunk as it arrives. The streaming reduces the time-to-first-byte for the slow loaders. The non-streaming SSR waits for all the loaders before sending the HTML.
The trade-off. The SSR is more complex than the CSR: the server has more moving parts (the router, the query client, the HTML template), and the debugging is harder. The benefit is the SEO and the perceived performance. The CSR is simpler, but the SEO and the first-paint are worse.
a. The server-side render and the dehydration
The server creates the router with the memory history, runs the loaders, renders the HTML, and dehydrates the state.
// server.tsx
import { createMemoryHistory, createRouter } from '@tanstack/react-router'
import { renderToString } from 'react-dom/server'
import { RouterProvider } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
async function render(url: string) {
const history = createMemoryHistory({ initialEntries: [url] })
const router = createRouter({ routeTree, history })
await router.load()
const html = renderToString(<RouterProvider router={router} />)
const dehydrated = router.dehydrate()
return { html, dehydrated }
}
The createMemoryHistory({ initialEntries: [url] }) sets the URL. The router.load() runs the loaders. The renderToString renders the HTML. The router.dehydrate() exports the state.
The HTML template includes the dehydrated state as the script.
function htmlTemplate(rendered: { html: string; dehydrated: any }) {
return `
<!DOCTYPE html>
<html>
<body>
<div id="root">${rendered.html}</div>
<script>
window.__DEHYDRATED_STATE__ = ${JSON.stringify(rendered.dehydrated)};
</script>
<script src="/client.js"></script>
</body>
</html>
`
}
The window.__DEHYDRATED_STATE__ is the global variable. The client reads it during the hydration.
b. The client hydration
The client creates the router with the browser history, hydrates the dehydrated state, and renders the application.
// client.tsx
import { createRouter } from '@tanstack/react-router'
import { hydrateRoot } from 'react-dom/client'
import { RouterProvider } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const dehydrated = window.__DEHYDRATED_STATE__
const router = createRouter({ routeTree, hydrationData: dehydrated })
hydrateRoot(document.getElementById('root')!, <RouterProvider router={router} />)
The createRouter({ routeTree, hydrationData: dehydrated }) uses the hydrationData to skip the loader re-fetch. The hydrateRoot reconciles the server HTML with the client React tree. The RouterProvider renders the application with the data already available.
The difference between the createRoot and the hydrateRoot is the server HTML. The createRoot replaces the HTML; the hydrateRoot reuses it. The hydrateRoot is the correct choice for the SSR.
c. The TanStack Query integration
The TanStack Query has the SSR integration with the setupRouterSsrQueryIntegration.
// server.tsx
import { QueryClient } from '@tanstack/react-query'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import { createMemoryHistory, createRouter } from '@tanstack/react-router'
import { renderToString } from 'react-dom/server'
import { routeTree } from './routeTree.gen'
async function render(url: string) {
const queryClient = new QueryClient()
const history = createMemoryHistory({ initialEntries: [url] })
const router = createRouter({ routeTree, history, context: { queryClient } })
setupRouterSsrQueryIntegration({ router, queryClient })
await router.load()
const html = renderToString(<RouterProvider router={router} />)
const dehydrated = router.dehydrate()
return { html, dehydrated }
}
The setupRouterSsrQueryIntegration wires the router and the query. The router.dehydrate() includes the query cache. The client hydrates both the router and the query with the dehydrated state.
// client.tsx
import { QueryClient } from '@tanstack/react-query'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import { createRouter } from '@tanstack/react-router'
import { hydrateRoot } from 'react-dom/client'
import { routeTree } from './routeTree.gen'
const dehydrated = window.__DEHYDRATED_STATE__
const queryClient = new QueryClient()
const router = createRouter({
routeTree,
hydrationData: dehydrated,
context: { queryClient },
})
setupRouterSsrQueryIntegration({ router, queryClient })
hydrateRoot(document.getElementById('root')!, <RouterProvider router={router} />)
The setupRouterSsrQueryIntegration on the client hydrates the query cache from the dehydrated state. The server-fetched queries are available on the client without the duplicate fetch. The QueryClient is created inside the client bootstrap, which is the per-application scope.
The QueryClient on the server is created per request. The per-request client is the correct choice for the SSR, because the shared client leaks the data between the users.
Complete Example Session
# ============================================
# PART 1: SERVER ROUTER
# ============================================
const history = createMemoryHistory({ initialEntries: [url] })
const router = createRouter({ routeTree, history })
# ============================================
# PART 2: SERVER LOAD
# ============================================
await router.load()
# ============================================
# PART 3: RENDER
# ============================================
const html = renderToString(<RouterProvider router={router} />)
# ============================================
# PART 4: DEHYDRATE
# ============================================
const dehydrated = router.dehydrate()
# ============================================
# PART 5: TEMPLATE
# ============================================
<script>window.__DEHYDRATED_STATE__ = ${JSON.stringify(dehydrated)}</script>
# ============================================
# PART 6: CLIENT HYDRATE
# ============================================
const router = createRouter({ routeTree, hydrationData: dehydrated })
hydrateRoot(document.getElementById('root'), <RouterProvider router={router} />)
# ============================================
# PART 7: QUERY INTEGRATION
# ============================================
const queryClient = new QueryClient()
setupRouterSsrQueryIntegration({ router, queryClient })
# ============================================
# PART 8: HYDRATE QUERY
# ============================================
const dehydrated = window.__DEHYDRATED_STATE__
const queryClient = new QueryClient()
# ============================================
# PART 9: VERIFY
# ============================================
# Server sends HTML → browser paints → client hydrates → no refetch.
The nine parts trace the SSR: server router, load, render, dehydrate, template, client hydrate, query integration, hydrate query, verification.
Quick Reference
Server Side
| Function | Purpose |
|---|---|
createMemoryHistory | In-memory URL |
router.load() | Run the loaders |
renderToString | Render to HTML |
router.dehydrate() | Export the state |
Client Side
| Function | Purpose |
|---|---|
createBrowserHistory | Browser URL |
hydrateRoot | Hydrate the HTML |
hydrationData | Dehydrated state |
router.hydrate() | Import the state |
Query Integration
| Function | Purpose |
|---|---|
setupRouterSsrQueryIntegration | Wire the router and the query |
QueryClient per request | Prevent the data leak |
Differences
| CSR | SSR |
|---|---|
createRoot | hydrateRoot |
| No dehydrated state | Dehydrated state in the HTML |
| No server | Server renders the HTML |
Best Practices
✅ Do This:
const router = createRouter({ routeTree, history })
<!-- Create the router per request on the server --> # ✅
const dehydrated = router.dehydrate()
<!-- Export the state for the client --> # ✅
hydrateRoot(document.getElementById('root'), <RouterProvider router={router} />)
<!-- Hydrate the server HTML --> # ✅
❌ Don’t Do This:
# Create the router at module scope on the server
<!-- The data leaks between the requests --> # ❌
# Use createRoot for the SSR
<!-- The server HTML is discarded --> # ❌
# Skip the dehydration
<!-- The client re-fetches the data --> # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Hydration mismatch | The server/client render differs | Ensure the same data |
| Data leak between requests | Router/query client at module scope | Create per request |
| Duplicate fetch | No dehydration | Use dehydrate/hydrate |
| SSR errors | The loader throws | Handle with the errorComponent |
| Slow TTFB | No streaming | Use the streaming SSR |
Real-World Examples
1. Server Router
createRouter({ routeTree, history: createMemoryHistory({ initialEntries: [url] }) })
2. Await Load
await router.load()
3. Render
renderToString(<RouterProvider router={router} />)
4. Dehydrate
const dehydrated = router.dehydrate()
5. Template
<script>window.__DEHYDRATED_STATE__ = ${JSON.stringify(dehydrated)}</script>
6. Hydrate Root
hydrateRoot(root, <RouterProvider router={router} />)
7. Hydration Data
createRouter({ routeTree, hydrationData: dehydrated })
8. Query Integration
setupRouterSsrQueryIntegration({ router, queryClient })
9. Per Request
const queryClient = new QueryClient()
10. Streaming
// Use the TanStack Start for the streaming SSR
Visual
SSR Flow
┌──────────────────────────────────────────────┐
│ Browser request /posts │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Server │
│ ├── createMemoryHistory │
│ ├── router.load() │
│ ├── renderToString │
│ └── router.dehydrate() │
└──────────────────┬───────────────────────────┘
│
│ HTML + dehydrated state
▼
┌──────────────────────────────────────────────┐
│ Browser │
│ ├── paint HTML │
│ ├── hydrateRoot │
│ └── router hydrates from dehydrationData │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Interactive │
└──────────────────────────────────────────────┘
SSR vs CSR
┌──────────────────────────────────────────────┐
│ CSR │
│ 1. Send the empty HTML │
│ 2. Load the JavaScript │
│ 3. Fetch the data │
│ 4. Render │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ SSR │
│ 1. Fetch the data on the server │
│ 2. Render the HTML │
│ 3. Send the HTML + state │
│ 4. Hydrate │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Server history | createMemoryHistory |
| Server load | router.load() |
| Server render | renderToString |
| Dehydration | router.dehydrate() |
| Client hydrate | hydrateRoot |
| Hydration data | hydrationData |
| Query integration | setupRouterSsrQueryIntegration |
| Per request | QueryClient |
Key takeaways:
- The SSR renders the HTML on the server. The
createMemoryHistorysets the URL. Therouter.load()runs the loaders. TherenderToStringproduces the HTML. Therouter.dehydrate()exports the state. - The client hydrates the server HTML. The
hydrateRootreuses the HTML. ThehydrationDataskips the loader re-fetch. ThecreateRootis for the CSR; thehydrateRootis for the SSR. - The data leaks are the trap. The router and the query client must be created per request. The module-scope instances are shared between the requests, which leaks the user data.
- The TanStack Query integration is the automatic. The
setupRouterSsrQueryIntegrationwires the router and the query. The dehydrated state includes the query cache. The client hydrates both. - The streaming SSR is the advanced. The TanStack Start provides the streaming. The chunks arrive as the loaders resolve. The client hydrates each chunk as it arrives.
Remember: The server renders the HTML. The client hydrates the HTML. The dehydrate is the export. The hydrate is the import. The createMemoryHistory is the server. The hydrateRoot is the client. The per-request is the isolation. The exam tests the SSR flow and the dehydration.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!