How to keep your React code SSG-safe by avoiding browser globals
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:
windowdocumentlocalStoragenavigator,matchMedia, and friends
So any code that runs during render and touches one of them throws:
// 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.
useEffectcallbacks 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:
// 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.
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.
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.
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, andlocalStorageare all undefined. - Reading a browser global during render (or at module load) crashes the build.
- The rule: touch browser globals only inside
useEffectcallbacks 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.