Skip to content
Twilight React Playground
ثيم رائدaren

Server rendering and hydration

Beginner10 min

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.

  1. 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.
  2. 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.

ExpressionOn the serverIn your browser, now
typeof window'undefined'
typeof document'undefined'
typeof localStorage'undefined'
typeof window.SallaReferenceError: 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.

1. The server's HTML, and the browser's first render
Your screen is isClient: false
2. After hydration (what you see now)
Your screen is isClient: false
The first render shows "…", because the server has no window. After the component mounts in the browser the hook flips to true.Try this: reload the page and watch the values: the dots come first, then the real width and language.
Storefront canvas · en · LTR

isClient: false

Window width:

Browser language:

What a theme writes
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.

ScreenWidth.tsx (broken)
// ✗ The server stops here: "ReferenceError: window is not defined"
export function ScreenWidth() {
  const width = window.innerWidth;
  return <span>{width}px</span>;
}
ScreenWidth.tsx (fixed)
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.

Greeting.tsx (mismatch)
// ✗ 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>;
}
Greeting.tsx (fixed)
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
  • useIsClient is exported only from the @salla.sa/twilight-theme-engine/hooks barrel. It is useState(false) flipped in a mount effect (src/hooks/useIsClient.ts), so every component's first render sees false, including one mounted after a client-side navigation, where nothing is hydrated.
  • HookSlot renders its salla-hook element (where Salla apps inject their markup) only after hydration, unless it is given ssr (src/hooks/HookSlot.tsx).
  • useWishlist answers [] on the server, useTwilight().authToken is null in the server render, and useDate().ago() reads the clock: render all three after hydration.
  • TwilightProvider starts the SDK in an effect. window.Salla exists once the module script has run, but wait for Salla.onReady() before calling it.
  • app/routes/__root.tsx puts suppressHydrationWarning on html and body, 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.
Check yourself

Where should localStorage.getItem('visited') go in a component?