Server rendering and hydration
Every page runs twice, on the server then in the browser: what exists where, and how to avoid "window is not defined".
A <script> on an ordinary web page runs in one place: the browser. Your theme's components run in two places, one after the other.
- On the server. When a shopper opens a page, the server calls your components and turns their JSX into HTML. This is server-side rendering (SSR). The page appears before any JavaScript has loaded, and search engines read that HTML.
- In the browser. The same components run again and React attaches itself to the HTML that is already on screen: click handlers, state, effects. This is hydration.
For hydration to work, the browser's first render must produce exactly the HTML the server sent.
What exists where
The server is not a browser. It has no window, no page, no local storage and no Salla SDK, but it can fetch and format. The right-hand column is read in your browser right now.
| Expression | On the server | In your browser, now |
|---|---|---|
typeof window | 'undefined' | … |
typeof document | 'undefined' | … |
typeof localStorage | 'undefined' | … |
typeof window.Salla | ReferenceError: window is not defined | … |
typeof fetch | 'function' | … |
typeof Intl | 'object' | … |
One component, two renders
The engine's useIsClient() hook returns false during the server render and during hydration, then true once the component is running in the browser. Below, the same component is drawn twice: once as the server and the first browser render see it, once as it is now. Resize the window and reload.
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>;
}
"window is not defined"
This is the most common theme error. Code that reads window, document, localStorage or window.Salla while the component function runs, or at the top of a file, also runs on the server, where those names do not exist.
// ✗ The server stops here: "ReferenceError: window is not defined"
export function ScreenWidth() {
const width = window.innerWidth;
return <span>{width}px</span>;
}
import { useEffect, useState } from 'react';
// ✓ Read browser things in an effect: it runs only in the browser, after hydration
export function ScreenWidth() {
const [width, setWidth] = useState<number | null>(null);
useEffect(() => {
const update = () => setWidth(window.innerWidth);
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return <span>{width === null ? '…' : `${width}px`}</span>;
}
Three places are always safe for browser-only code: inside useEffect, inside an event handler such as onClick (a shopper can only click in a browser), and behind useIsClient().
The quieter mistake: a mismatch
Checking typeof window avoids the crash, but makes the two renders disagree. React notices that the browser's first render differs from the server's HTML, reports a hydration error in the console, and throws the server's HTML for that part away.
// ✗ Runs without crashing, but the server says "Guest" and the browser may say
// "Welcome back": React reports a hydration error and redraws that part.
export function Greeting() {
const returning = typeof window !== 'undefined' && localStorage.getItem('visited') === '1';
return <p>{returning ? 'Welcome back' : 'Guest'}</p>;
}
import { useIsClient } from '@salla.sa/twilight-theme-engine/hooks';
// ✓ Render what the server rendered first, then the browser-only version
export function Greeting() {
const isClient = useIsClient();
const returning = isClient && localStorage.getItem('visited') === '1';
return <p>{returning ? 'Welcome back' : 'Guest'}</p>;
}
In engine terms
useIsClientis exported only from the@salla.sa/twilight-theme-engine/hooksbarrel. It isuseState(false)flipped in a mount effect (src/hooks/useIsClient.ts), so every component's first render seesfalse, including one mounted after a client-side navigation, where nothing is hydrated.HookSlotrenders itssalla-hookelement (where Salla apps inject their markup) only after hydration, unless it is givenssr(src/hooks/HookSlot.tsx).useWishlistanswers[]on the server,useTwilight().authTokenisnullin the server render, anduseDate().ago()reads the clock: render all three after hydration.TwilightProviderstarts the SDK in an effect.window.Sallaexists once the module script has run, but wait forSalla.onReady()before calling it.app/routes/__root.tsxputssuppressHydrationWarningonhtmlandbody, whose attributes the engine and the SDK change in the browser. It only silences those two elements, never their children.- The concept Request lifecycle shows where rendering and hydration sit in a request; reference: useIsClient.