12k
All articles

Fixing 'window is not defined' in Server-Rendered Apps

Fix window is not defined errors in server-rendered apps with on-mount hooks, typeof window guards, and client-only imports for dependencies.

OpenReplay Team
OpenReplay Team
Fixing 'window is not defined' in Server-Rendered Apps

The error window is not defined means your code executed in Node.js, where no window object exists: server-rendered frameworks run your components on the server first, before any browser is involved.

The error usually arrives right after you add server rendering to a working app, or move a component that worked fine client-side into Next, Nuxt, SvelteKit, Astro, or React Router. The component didn’t change. Where it runs did, and the stack trace tells you which of the three fixes below you need.

Key Takeaways

  • window is not defined means the code ran in Node.js, where window never exists at any point in any lifecycle; it is not a timing problem.
  • The default fix is moving the access into an on-mount hook (useEffect, onMounted, onMount), because mount hooks never run on the server.
  • A typeof window !== 'undefined' guard belongs in module-level and shared-utility code; inside a component’s render it makes server and client HTML diverge.
  • Client-only rendering is the last resort: it removes the component from the server’s HTML entirely.
  • The same crash can happen during the build, because static generation runs components in Node to produce HTML.

Why Does ‘window is not defined’ Happen in Server-Rendered Apps?

Server-rendered apps execute your components twice: once in Node.js to produce HTML, then again in the browser. The Node.js global scope includes no window and no document, so any code that touches them during the server pass throws a ReferenceError. The object is not “not yet available”; in Node it never exists at all.

function ThemeBadge() {
  // ReferenceError: window is not defined (thrown during the server render)
  const theme = window.localStorage.getItem('theme');
  return <span>{theme}</span>;
}

The same applies with no request in sight. Static generation runs your components in Node at build time to produce HTML, so a window access can fail during next build or prerendering, with the stack trace appearing in build output instead of a server log. SvelteKit even exposes this phase as the building constant, which is true during prerendering. A component that only ever renders on the client in dev can therefore pass local testing and still break the production build.

What If the Crash Is in a Dependency?

If the top frames of the stack trace point into node_modules, a dependency is reading window at import time, and it throws before any of your component code runs. Charting libraries, embed SDKs, and anything that probes the DOM at module scope are the usual suspects.

ReferenceError: window is not defined
    at node_modules/some-chart-lib/dist/index.js:12:3
    at Module._compile (node:internal/modules/cjs/loader:1358:14)

This distinction decides the fix. An import-time throw fires when the module loads, so wrapping your own usage in an on-mount hook cannot help; the crash happens before the component exists. For these packages, skip to the client-only import in fix three.

Fix 1: Move the Access Into an On-Mount Hook

The default fix is to move the window access into your framework’s on-mount hook, because mount hooks only ever run in the browser. React’s useEffect reference is explicit about this: the server render skips Effects, and they fire only once the component reaches the browser. The equivalents: Vue and Nuxt use onMounted, Svelte and SvelteKit use onMount, which a component rendered on the server never calls, React Router uses React’s useEffect, and Astro components put browser code in a framework island’s lifecycle hooks.

import { useState, useEffect } from 'react';

function ThemeBadge() {
  const [theme, setTheme] = useState(null);

  useEffect(() => {
    setTheme(window.localStorage.getItem('theme')); // browser only
  }, []);

  return <span>{theme ?? 'default'}</span>;
}

The server renders the fallback state, the browser mounts, the effect runs, and the real value fills in. This keeps server HTML for the rest of the component intact, which is why it beats the other two fixes as a default.

Fix 2: Guard With typeof window !== ‘undefined’

A typeof window !== 'undefined' guard is the right tool for module-level code and shared utilities, where no lifecycle hook is available.

// theme.js — a shared utility, no component lifecycle to lean on
export function getStoredTheme() {
  if (typeof window === 'undefined') return 'light'; // server fallback
  return window.localStorage.getItem('theme') ?? 'light';
}

SvelteKit offers a cleaner equivalent in the browser constant, and its FAQ on client-side libraries treats that constant as the standard way to fence off anything that touches document or window.

Inside a component’s render, though, the guard is a poor fit: it makes the server and the browser produce different HTML for the same component, trading a crash for a mismatch when the client takes over. Keep the guard in plain functions and module scope; use fix one inside components.

Fix 3: Skip Server Rendering for the Component

The last resort is a client-only dynamic import, which excludes the component from server rendering entirely. In Next.js, next/dynamic with ssr: false does this inside a Client Component (it errors in Server Components, so add a thin 'use client' wrapper). Nuxt has <ClientOnly>, and Astro has the client:only directive.

'use client';
import dynamic from 'next/dynamic';

const Chart = dynamic(() => import('./Chart'), {
  ssr: false,
  loading: () => <div style={{ height: 320 }} aria-hidden="true" />,
});

Name the cost before reaching for this: the server sends no HTML for that subtree, so the component is absent from the initial HTML, which can hurt SEO and delay interactivity. Reserve it for components you cannot change, chiefly dependencies that throw at import time.

Avoid the Pop-In With a Same-Shape Placeholder

A placeholder only prevents layout shift if it occupies the same dimensions as the component it stands in for. Rendering null on the server means the component appears from nowhere once JavaScript runs, shoving everything below it down the page. A fixed-footprint skeleton, like the 320px div above, holds the space until the real markup arrives. Whether to render a placeholder or null at all is the same trade-off behind many hydration mismatches, covered in depth in our guide to fixing Next.js hydration errors. Session replays of client-only fallbacks make the placeholder-to-content swap visible as a layout jump, which is the quickest way to check whether a placeholder actually matches the markup it replaces.

Which Fix Fits Your Case?

  1. Your component reads window in its own code: move the access into the on-mount hook. Default choice.
  2. A shared utility or module-level statement touches window: add the typeof window guard with a server fallback value.
  3. The stack trace points into node_modules at import time: client-only dynamic import, with a same-shape placeholder.
  4. The error appears only in build output: same triage as above; static generation runs the identical code path in Node.

Read the Stack Trace First

The error is an environment problem, not a timing problem: some line of code ran in Node, where window has never existed. Read the stack trace first. If the top frame is yours, an on-mount hook or a guard fixes it while keeping server HTML. If it points into node_modules, isolate the dependency behind a client-only import and give it a placeholder that holds the layout.

FAQs

Is 'document is not defined' the same problem as 'window is not defined'?

Yes. Both errors have the same cause: the code ran in Node.js, whose global scope includes neither window nor document. The same triage and the same three fixes apply, so move the access into an on-mount hook, guard module-level code with a typeof check, or render the component client-only when a dependency touches the DOM at import time.

Can I fix the error by defining a global window object on the server?

Avoid it. Assigning a fake window to globalThis silences the ReferenceError, but the server then renders markup from fake values, and anything stored on the polyfill is shared across every request the server handles. It also hides import-time crashes in dependencies instead of surfacing them. Move the access into an on-mount hook or behind a typeof window guard instead.

Why does 'window is not defined' still appear after setting ssr: false in Next.js?

Two common reasons. In the App Router, next/dynamic accepts ssr: false only from a Client Component, and Next.js raises an error when the option turns up in a Server Component, so wrap it in a thin 'use client' component. Also, ssr: false only affects that dynamic import: if another server-executed file imports the same library statically, its module-level window access still runs in Node.

Does localStorage exist in Node.js?

Partially. Node has shipped a localStorage global since v22.4.0, unflagged from v25.0.0, which persists up to 10 MB in the file passed via the --localstorage-file flag; in v26, accessing it without that flag throws a DOMException. On a server there is one store behind it for the whole process, not one per visitor or per request, so it is nothing like the browser's per-user storage, and window.localStorage still throws because window itself never exists in Node.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.