ehsan.blog
~/blog/how-to-avoid-browser-globals-in-ssg-react — zsh
cat how-to-avoid-browser-globals-in-ssg-react.md

How to keep your React code SSG-safe by avoiding browser globals

·4 min read

The first time I ran a prerendered build of my portfolio, it crashed with window is not defined. Nothing was wrong with the code in the browser — it was wrong in Node. When you prerender a React app with vite-react-ssg (Static Site Generation), the build renders your components in Node, where the browser’s global objects simply don’t exist. Here’s the rule that keeps your code safe, and a real hook that follows it.

Why browser globals crash a Node prerender

With SSG, your build renders the React tree once in Node to produce static HTML. Node is a JavaScript runtime, but it is not a browser — so these globals that you take for granted are all undefined:

  • window
  • document
  • localStorage
  • navigator, matchMedia, and friends

So any code that runs during render and touches one of them throws:

tsx
// This crashes the build:
function Widget() {
  const width = window.innerWidth   // ReferenceError in Node
  return <div>{width}</div>
}

The component body runs synchronously during the server render, window doesn’t exist, and the build dies. Same story for reading localStorage for a saved theme — one reason my site instead forces dark mode with a class in the HTML — or document.getElementById at module load.

The rule: only touch browser globals in effects or handlers

Here’s the mental model. Code in a React component runs in two very different places:

  • The component body and module top-level run both in Node (at build) and in the browser. This code must be browser-global-free.
  • useEffect callbacks and event handlers run only in the browser. React never executes effects during server rendering. This is where browser globals are safe.
flowchart TD
  A[Module load and render] -->|runs in Node AND browser| B[No window / document here]
  C[useEffect and event handlers] -->|browser only| D[window / document safe here]

So the rule is simple: never read window, document, or localStorage at module load or during render. Only touch them inside effects or event handlers.

The useIsMobile pattern

My layout branches on screen size — desktop gets a horizontal slideshow, mobile gets a plain vertical stack. That needs window.innerWidth, a browser global. If I read it during render, the build crashes. Here’s the actual hook that does it safely:

ts
// src/hooks/use-mobile.ts
import * as React from "react"

const MOBILE_BREAKPOINT = 768

export function useIsMobile() {
  const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)

  React.useEffect(() => {
    const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
    const onChange = () => {
      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
    }
    mql.addEventListener("change", onChange)
    setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
    return () => mql.removeEventListener("change", onChange)
  }, [])

  return !!isMobile
}

Walk through what makes this SSG-safe:

1. Initial state is undefined, not a window read.

ts
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)

During the server render, the hook returns without ever touching window — the state is just undefined. No crash. Note the state type is boolean | undefined precisely so “we don’t know yet” is a valid, honest value.

2. Every window access lives inside useEffect.

ts
React.useEffect(() => {
  const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
  // ...
  setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
  // ...
}, [])

Effects only run in the browser, so window.matchMedia and window.innerWidth are guaranteed to exist here. The effect also subscribes to a media query so the value updates when the viewport crosses the breakpoint, and returns a cleanup function to remove the listener.

3. The return coerces to a plain boolean.

ts
return !!isMobile

!!undefined is false. So on the server (and the very first client render, before the effect runs) the hook reports false — treat “not mobile” as the safe default that the desktop and Node renders agree on. The effect then flips it to the real value on the client.

Why the undefined-first shape matters for hydration

There’s a bonus reason this pattern is shaped the way it is. Remember that after prerendering, React hydrates — the first client render must match the server’s HTML. Because the server rendered with isMobile as false (from !!undefined), the first client render also starts at false, so they match. The effect updates the value afterward, which React handles as a normal state change rather than a mismatch. If the hook tried to read window.innerWidth during the initial render, the client and server could disagree and you’d get a hydration warning on top of the build crash. The same reasoning drives making typing animations SSG-safe and building an SSR-safe error boundary.

What to remember

  • In SSG, components render in Node, where window, document, and localStorage are all undefined.
  • Reading a browser global during render (or at module load) crashes the build.
  • The rule: touch browser globals only inside useEffect callbacks or event handlers — those run in the browser only.
  • Seed state with a neutral default (undefined / false), then read the real value inside an effect.
  • This same pattern also keeps hydration clean, because server and first client render agree on the default.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.