Server rendering and hydration
Your components run twice, once on the server and once in the browser, and both runs must draw the same page.
A storefront page is drawn twice. First on the server, so the shopper (and search engines) get a finished page immediately. Then again in the browser, so the page becomes interactive. The second drawing is called hydration, and it only works when both drawings match.
Server rendering is often shortened to SSR. Step through what runs where.
1. The server runs first
A page request reaches the server. There, the page data is loaded and your components run once. The server is not a browser: there is no window, no document, no localStorage and nobody to click.
In engine terms
TanStack Start on the server. twilightMiddleware() and the root beforeLoad run, then the matched routes' loaders. On the server getRouter() builds a new router and QueryClient for every request (app/router.tsx). Loaders run on the server for the first page and in the browser for later navigations, so a loader must never touch browser-only APIs.
Avoiding a mismatch
The rule: a component's first render must not depend on anything only the browser knows. Start with a value the server can also compute, then switch in an effect.
export function Greeting() {
// Server: "Welcome". Browser: "Welcome back". React reports a mismatch.
const returning = typeof window !== 'undefined' && localStorage.getItem('visited');
return <p>{returning ? 'Welcome back' : 'Welcome'}</p>;
}
import { useEffect, useState } from 'react';
export function Greeting() {
// Same value on the server and in the first browser render.
const [returning, setReturning] = useState(false);
useEffect(() => {
// Effects run only in the browser, after hydration.
setReturning(Boolean(localStorage.getItem('visited')));
}, []);
return <p>{returning ? 'Welcome back' : 'Welcome'}</p>;
}
For a quick "only after mount" value, the engine's useIsClient() does the same thing:
import { useIsClient } from '@salla.sa/twilight-theme-engine/hooks';
export function ScreenWidth() {
const isClient = useIsClient(); // false on the server and in the first browser render
return <span>{isClient ? window.innerWidth : '…'}</span>;
}
isClient: false
Window width: …
Browser language: …
import { useIsClient } from '@salla.sa/twilight-theme-engine/hooks';
export function WindowWidth() {
const isClient = useIsClient();
// Never branch on `typeof window` in render: the server and the first browser render must match.
return <span>{isClient ? window.innerWidth : '…'}</span>;
}
Why it matters
- "window is not defined" (or
document,localStorage): the code ran on the server, in render or in a loader. Move it intouseEffector an event handler. - Text or layout flashes right after load, with a hydration error in the console: the first browser render differed from the server's. Find the value that depends on the browser and switch it in an effect.
- Data fetched in `useEffect` is missing from the first paint and from what search engines read: fetch it in the route's loader instead, so it is in the server HTML. See Reading and writing data.
- A loader returns a function (a callback, a formatter): the server cannot send it to the browser, and the render fails with
SerovalUnsupportedTypeError. Return plain data and keep functions in components.
Go deeper: useIsClient, getTwilightContext, createRouter, shouldDehydrateQuery and HookSlot. The whole journey is in Request lifecycle.