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

Server rendering and hydration

Beginner8 min

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.

Requestserver · first hopLoaders runserver · no windowComponents runserver · to HTMLHTML + dataone responsePage is visiblebrowser · not liveHydrationbrowser · same renderMismatch?server ≠ browserEffects runbrowser onlySDK readytheme::ready

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.

Wrong: differs between server and browser
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>;
}
Right: switch after hydration
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:

app/components/ScreenWidth.tsx
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>;
}
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 · ar · RTL

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>;
}

Why it matters

  • "window is not defined" (or document, localStorage): the code ran on the server, in render or in a loader. Move it into useEffect or 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.
Check yourself

A component renders window.innerWidth directly. What happens when a shopper opens the page?