Node.js Error

ReferenceError: document is not defined

error type: ReferenceError  ·  globals: document, window, navigator, localStorage, location

Complete reference — why Node.js has no DOM, how this error appears in Next.js App Router, Gatsby, Astro, Remix, Nuxt, Jest, and shared isomorphic code, and how to fix every case with typeof guards, dynamic imports, lifecycle hooks, and client directives.

Quick Answer: Node.js has no browser DOM. Globals like document, window, navigator, and localStorage do not exist in Node.js. Guard every access with if (typeof document !== 'undefined'), move browser code into useEffect / onMounted, or use Next.js dynamic() with ssr: false. In Next.js App Router, add 'use client' and move access inside useEffect. For Jest/Vitest, add testEnvironment: 'jsdom' to your config.

What does "ReferenceError: document is not defined" mean?

Node.js is a server-side JavaScript runtime built on V8. Unlike a web browser, it has no Document Object Model (DOM) and does not expose browser globals such as document, window, navigator, localStorage, or location. When any code — your own or a third-party library — references one of these globals in a Node.js process, the JavaScript engine cannot find the identifier in the scope chain and throws a ReferenceError.

This error is particularly common in isomorphic (universal) JavaScript applications that share code between the browser and server, such as Next.js, Nuxt.js, Remix, Gatsby, and Astro SSR. It also appears frequently when running browser-targeted code through Node.js test runners like Jest or Vitest with their default node environment.

Exact error messages you will see:
ReferenceError: document is not defined
ReferenceError: window is not defined
ReferenceError: navigator is not defined
ReferenceError: localStorage is not defined
ReferenceError: location is not defined
ReferenceError: sessionStorage is not defined
ReferenceError: crypto is not defined (browser crypto global, not Node.js crypto module)

Full Error Example

ReferenceError: document is not defined
    at Object.<anonymous> (/app/lib/analytics.js:3:1)
    at Module._compile (node:internal/modules/cjs/loader:1364:14)
    at Module._extensions..js (node:internal/modules/cjs/loader:1422:10)
    at Module.load (node:internal/modules/cjs/loader:1203:32)
    at Module._load (node:internal/modules/cjs/loader:1019:12)
    at Module.require (node:internal/modules/cjs/loader:1231:19)
    at require (node:internal/modules/helpers:179:18)
    at Object.<anonymous> (/app/pages/index.js:1:18)

Notice that the error originates inside analytics.js at line 3, but the stack trace shows it was triggered by require() in pages/index.js. This pattern — error inside a library, triggered at your import — means the library accesses document or window at module load time (top level), not inside a function. You cannot defer the access by calling the function later; you must prevent the import from running on the server at all.

Common Causes

CauseWhy it happens
Direct DOM access in shared isomorphic code A utility module references document.cookie or window.location at the top level. When Node.js imports the module for SSR, the reference is evaluated immediately.
Importing a browser-only npm library Some packages (analytics SDKs, charting libraries, UI toolkits) access document or window at import time. Using them in a server-side module propagates the error.
SSR rendering a React/Vue component that accesses document on mount If DOM access is outside useEffect / onMounted — e.g., in the component body or in a top-level variable initializer — it runs during the server render pass.
Next.js App Router — missing 'use client' or access outside useEffect In Next.js 13+ App Router, components are server components by default. Even with 'use client', the component is pre-rendered on the server before hydration, so window/document access must be inside useEffect.
Jest or Vitest with default node test environment Jest's default testEnvironment is 'node', which has no DOM. Tests that import components or utilities touching browser globals fail immediately.
Browser globals accessed at module load time Code like const ua = navigator.userAgent at the top of a file evaluates when the module is first require()-d or import-ed, not when a function is called.
Third-party analytics or tracking scripts used in SSR Analytics libraries (Google Tag Manager, Segment, Hotjar, Mixpanel) are designed for browsers only. They typically reference document and window immediately on load.
Astro frontmatter accessing browser APIs Code in the Astro --- frontmatter section runs at build time and on the server. Only scripts inside <script> tags or framework components with client:* directives run in the browser.

Fix 1 – Guard with typeof check (universal)

The most portable fix for any environment. Before accessing a browser global, check whether it exists using typeof. Unlike a direct reference, typeof does not throw a ReferenceError for undefined identifiers.

// Bad – throws ReferenceError in Node.js
const theme = document.documentElement.getAttribute('data-theme');

// Good – safe in both browser and Node.js
const theme =
  typeof document !== 'undefined'
    ? document.documentElement.getAttribute('data-theme')
    : null;

// Guard a whole block
if (typeof window !== 'undefined') {
  window.addEventListener('resize', handleResize);
}

// Guard localStorage access
function getStoredUser() {
  if (typeof localStorage === 'undefined') return null;
  return JSON.parse(localStorage.getItem('user') ?? 'null');
}

// Guard navigator.userAgent sniffing
function getUserAgent() {
  if (typeof navigator === 'undefined') return '';
  return navigator.userAgent;
}
Why typeof and not a try/catch? typeof undeclaredVar returns 'undefined' without throwing. A bare if (document) still throws ReferenceError in Node.js. Always use typeof for global existence checks.

Reusable isBrowser helper

Instead of repeating typeof window !== 'undefined' throughout your codebase, create a shared utility. Bundlers tree-shake the server branch when building for the browser.

// utils/environment.js
export const isBrowser = typeof window !== 'undefined';
export const isServer  = !isBrowser;

// Usage
import { isBrowser } from './utils/environment';

if (isBrowser) {
  window.localStorage.setItem('visited', 'true');
  document.title = 'Welcome back!';
}

Fix 2 – Next.js Pages Router: dynamic import with ssr: false

When a React component or a library it imports accesses browser globals at the module level, you cannot simply guard inside the component — the import itself triggers the error. Use next/dynamic with ssr: false to defer loading entirely to the browser.

// pages/dashboard.tsx
import dynamic from 'next/dynamic';

// The chart library accesses `document` at import time — never SSR it
const RevenueChart = dynamic(
  () => import('../components/RevenueChart'),
  {
    ssr: false,
    loading: () => <p>Loading chart...</p>,
  }
);

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      <RevenueChart />  {/* only rendered in the browser */}
    </main>
  );
}

Fix 3 – Next.js App Router (13+): 'use client' + useEffect

In Next.js 13+ App Router, all components are server components by default. Adding 'use client' makes a component a client component, but it is still pre-rendered on the server during the initial HTML generation. Therefore, you must move all window or document access inside a useEffect hook, which only runs after the component has been hydrated in the browser.

'use client';
// app/components/ThemeToggle.tsx

import { useState, useEffect } from 'react';

export function ThemeToggle() {
  const [theme, setTheme] = useState<string>('light');

  useEffect(() => {
    // Runs only in the browser, after hydration — never during SSR
    const stored = localStorage.getItem('theme') ?? 'light';
    setTheme(stored);
    document.documentElement.setAttribute('data-theme', stored);
  }, []);

  function toggle() {
    const next = theme === 'light' ? 'dark' : 'light';
    setTheme(next);
    localStorage.setItem('theme', next);
    document.documentElement.setAttribute('data-theme', next);
  }

  return <button onClick={toggle}>{theme} mode</button>;
}
// app/dashboard/page.tsx — dynamically import client-only components
import dynamic from 'next/dynamic';

const MapComponent = dynamic(() => import('../components/Map'), { ssr: false });

export default function DashboardPage() {
  return (
    <main>
      <MapComponent />
    </main>
  );
}
Common mistake with 'use client': Adding 'use client' does NOT mean the component skips server rendering. It still pre-renders on the server. You MUST move window / document access into useEffect or an event handler, otherwise you will still get ReferenceError: window is not defined.

Fix 4 – Move browser code to useEffect (React) or onMounted (Vue / Nuxt)

In React and Vue SSR, lifecycle hooks that only run in the browser are the correct place for any DOM interaction. The server render pass never calls useEffect or onMounted, so browser globals are only accessed after hydration.

// React – move DOM access inside useEffect
import { useState, useEffect, useRef } from 'react';

export function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0);
  const elementRef = useRef(null);  // prefer useRef over document.getElementById

  useEffect(() => {
    // Runs only in the browser, never during SSR
    function onScroll() {
      setScrollY(window.scrollY);
    }
    window.addEventListener('scroll', onScroll);
    return () => window.removeEventListener('scroll', onScroll);
  }, []);

  useEffect(() => {
    // Safe DOM manipulation via ref
    if (elementRef.current) {
      elementRef.current.style.opacity = '1';
    }
  }, []);

  return <div ref={elementRef}>Scroll position: {scrollY}px</div>;
}
// Vue 3 / Nuxt – move DOM access inside onMounted
<script setup>
import { ref, onMounted } from 'vue';

const scrollY = ref(0);

onMounted(() => {
  // Runs only in the browser, never during SSR
  scrollY.value = window.scrollY;
  window.addEventListener('scroll', () => {
    scrollY.value = window.scrollY;
  });
});
</script>
<!-- Nuxt 3: use <ClientOnly> wrapper for browser-only components -->
<template>
  <div>
    <ClientOnly>
      <BrowserOnlyComponent />
      <template #fallback>
        <p>Loading...</p>
      </template>
    </ClientOnly>
  </div>
</template>

Fix 5 – Gatsby: gatsby-browser.js and componentDidMount

Gatsby performs static site generation (SSG) and server-side rendering using Node.js. Browser globals are unavailable during the build step. There are two Gatsby-specific approaches:

// gatsby-browser.js — this file ONLY runs in the browser
// Safe to access window, document, navigator here
export const onClientEntry = () => {
  // Initialize analytics, set up event listeners, etc.
  window.dataLayer = window.dataLayer || [];
  document.body.classList.add('js-loaded');
};

export const onRouteUpdate = ({ location }) => {
  // Track page views — window guaranteed to exist
  if (typeof window.gtag === 'function') {
    window.gtag('config', 'GA_MEASUREMENT_ID', {
      page_path: location.pathname,
    });
  }
};
// Gatsby React component — use lifecycle hooks or useEffect
import { useEffect } from 'react';

export function StickyHeader() {
  useEffect(() => {
    // gatsby-ssr.js renders this on server; useEffect runs only in browser
    const header = document.querySelector('header');
    const observer = new IntersectionObserver(([entry]) => {
      document.body.classList.toggle('sticky-header', !entry.isIntersecting);
    });
    if (header) observer.observe(header);
    return () => observer.disconnect();
  }, []);

  return <header>My Site</header>;
}

// Class component alternative
export class Analytics extends React.Component {
  componentDidMount() {
    // componentDidMount only runs in the browser, not during Gatsby SSR
    window.analytics.page();
  }
  render() { return null; }
}
Gatsby tip: Use typeof window !== 'undefined' guards in gatsby-ssr.js since that file runs in Node.js. The file gatsby-browser.js is always safe for browser globals.

Fix 6 – Astro: client directives and script placement

Astro renders components to static HTML on the server. Browser globals are unavailable in the Astro frontmatter (the --- section). Use client:* directives to hydrate interactive framework components, and use <script> tags for plain JavaScript that must run in the browser.

---
// astro frontmatter — runs on the SERVER (Node.js), no window/document here
import ReactChart from './ReactChart.jsx';
import VueSidebar from './VueSidebar.vue';

const title = "Dashboard";  // OK — no browser globals
// const width = window.innerWidth;  // WRONG — throws ReferenceError
---

<html>
  <body>
    <h1>{title}</h1>

    <!-- client:load hydrates immediately when page loads -->
    <ReactChart client:load />

    <!-- client:idle hydrates when browser is idle -->
    <VueSidebar client:idle />

    <!-- client:visible hydrates when element enters viewport -->
    <HeavyWidget client:visible />

    <!-- Plain script tag — Astro bundles this for browser execution -->
    <script>
      // window and document are available here
      document.querySelector('.menu-btn').addEventListener('click', () => {
        document.querySelector('nav').classList.toggle('open');
      });
    </script>
  </body>
</html>

Fix 7 – Remix: useEffect and loader boundaries

Remix runs loaders and actions exclusively on the server. Components themselves are rendered on both server and client. The same rule applies: use useEffect for browser-only code.

// app/routes/dashboard.tsx
import { useEffect, useState } from 'react';
import type { LoaderFunctionArgs } from '@remix-run/node';

// This runs on the server — no window/document
export async function loader({ request }: LoaderFunctionArgs) {
  const data = await fetchData();
  return Response.json(data);
}

export default function Dashboard() {
  const [windowWidth, setWindowWidth] = useState<number | null>(null);

  useEffect(() => {
    // Runs only in the browser after hydration
    setWindowWidth(window.innerWidth);
    const onResize = () => setWindowWidth(window.innerWidth);
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);

  return (
    <main>
      {windowWidth !== null && <p>Window width: {windowWidth}px</p>}
    </main>
  );
}

Fix 8 – Jest / Vitest: configure testEnvironment jsdom

Jest defaults to a 'node' test environment with no DOM. Switch to 'jsdom' to make document, window, and navigator available in all tests. Vitest uses the same option.

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',   // provides document, window, navigator
  // ... other options
};
// jest.config.ts (TypeScript)
import type { Config } from 'jest';

const config: Config = {
  testEnvironment: 'jsdom',
  setupFilesAfterFramework: ['@testing-library/jest-dom'],
};

export default config;
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',   // or 'happy-dom' for a faster alternative
  },
});
jest-environment-jsdom is a separate package: In Jest 27+, the jsdom environment was moved out of the core package. Install it separately: npm install --save-dev jest-environment-jsdom. Without it, Jest throws Cannot find module 'jest-environment-jsdom'.
# Install the jsdom environment for Jest 27+
npm install --save-dev jest-environment-jsdom

# For Vitest, jsdom is bundled — no extra install needed

To apply jsdom only to specific test files instead of globally, add a docblock at the top of the file:

/**
 * @jest-environment jsdom
 */

import { render, screen } from '@testing-library/react';
import { ThemeSwitcher } from './ThemeSwitcher';

test('renders theme button', () => {
  render(<ThemeSwitcher />);
  expect(screen.getByRole('button')).toBeInTheDocument();
});
// Vitest per-file environment override
// @vitest-environment jsdom

import { describe, it, expect } from 'vitest';

describe('DOM interactions', () => {
  it('reads document title', () => {
    document.title = 'Test Page';
    expect(document.title).toBe('Test Page');
  });
});

Fix 9 – Separate browser and server entry points

For libraries or shared utilities that need fundamentally different implementations per environment, create separate entry points and let the bundler or Node.js resolver select the correct file.

// package.json — use the "browser" field for bundlers (webpack, esbuild, Vite)
{
  "name": "my-storage-lib",
  "main": "./server.js",       // used by Node.js (require())
  "browser": "./browser.js",   // used by bundlers targeting browsers
  "exports": {
    "node": "./server.js",
    "browser": "./browser.js",
    "default": "./browser.js"
  }
}
// browser.js — real localStorage implementation
export function getItem(key) {
  return localStorage.getItem(key);
}

export function setItem(key, value) {
  localStorage.setItem(key, value);
}
// server.js — no-op / in-memory fallback for Node.js
const store = new Map();

export function getItem(key) {
  return store.get(key) ?? null;
}

export function setItem(key, value) {
  store.set(key, value);
}

Fix 10 – globalThis polyfill / mock for test environments

When you need a lightweight polyfill for tests or non-browser Node.js scripts without switching to jsdom, assign the missing globals to globalThis in a setup file.

// jest.setup.js  (referenced via setupFiles in jest.config.js)
const { JSDOM } = require('jsdom');

const dom = new JSDOM('', { url: 'http://localhost' });

globalThis.window    = dom.window;
globalThis.document  = dom.window.document;
globalThis.navigator = dom.window.navigator;

// Mock localStorage with a simple in-memory implementation
const localStorageMock = (() => {
  let store = {};
  return {
    getItem: (key) => store[key] ?? null,
    setItem: (key, value) => { store[key] = String(value); },
    removeItem: (key) => { delete store[key]; },
    clear: () => { store = {}; },
    get length() { return Object.keys(store).length; },
    key: (i) => Object.keys(store)[i] ?? null,
  };
})();

Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock });
Object.defineProperty(globalThis, 'sessionStorage', { value: localStorageMock });
// jest.config.js — reference the setup file
module.exports = {
  testEnvironment: 'node',
  setupFiles: ['./jest.setup.js'],
};

Debugging Checklist

Follow these steps to identify which code accesses a browser global at import time:

  1. Read the full stack trace. The first frame inside node_modules is the library accessing the global. The frame above it (in your code) shows the import or require() that triggered the load.
  2. If the offending frame is deep inside node_modules, check whether the library has a dedicated SSR build or an option to disable DOM access. Many UI libraries export separate /server or /ssr entry points.
  3. Search the library source for document, window, or navigator outside of any function — at the module's top level or in a class field initializer. These execute at import time and cannot be guarded by your code.
  4. Try importing the problematic module inside a useEffect or with a dynamic import() to confirm it is the source:
    useEffect(() => {
      import('suspect-library').then(({ doThing }) => doThing());
    }, []);
  5. Check whether the library's package.json has a "browser" field. If it does, your bundler may be ignoring it. Verify your webpack/Vite/esbuild target is set to 'web' for client bundles and 'node' for server bundles.
  6. For Next.js, enable verbose logging with NODE_OPTIONS='--trace-warnings' next dev to see which module triggers the warning before the ReferenceError.
  7. For Gatsby, run gatsby build locally instead of gatsby develop — the build step runs SSR and will reproduce the error even if develop works.
Hydration mismatch warning: Using typeof window !== 'undefined' inside component render logic can cause a hydration mismatch — the server renders one value (with window undefined) and the browser renders another. React will log a warning and may re-render. To avoid this, initialize state to a neutral value and update it in useEffect, or use dynamic() with ssr: false.

Quick Reference: Framework-Specific Fixes

FrameworkRecommended fix
Next.js Pages Router dynamic(() => import('./Comp'), { ssr: false }) or useEffect
Next.js App Router (13+) 'use client' + useEffect for window/document access
Gatsby useEffect / componentDidMount, or gatsby-browser.js
Astro client:load / client:idle directive, or <script> tag body
Remix useEffect in component; loaders run server-side only
Nuxt 3 onMounted, useNuxtApp().isHydrating, or <ClientOnly>
Jest (any) testEnvironment: 'jsdom' + jest-environment-jsdom
Vitest environment: 'jsdom' or // @vitest-environment jsdom
Plain Node.js script if (typeof document !== 'undefined') guard or jsdom package

Frequently Asked Questions

Why does Node.js throw ReferenceError: document is not defined?

Node.js has no DOM. Globals like document, window, navigator, localStorage, and location only exist in browser environments. When code written for a browser runs in Node.js — such as during server-side rendering, Jest tests, or by importing a browser-only library — Node.js cannot find these globals and throws ReferenceError.

How do I fix ReferenceError: window is not defined in Next.js App Router?

In Next.js 13+ App Router, add 'use client' at the top of the file. However, even client components are pre-rendered on the server, so you must move all window or document access inside a useEffect hook. For third-party libraries that access window at import time, use dynamic(() => import('./MyLib'), { ssr: false }).

'use client';
import { useEffect } from 'react';

export function WindowWidth() {
  useEffect(() => {
    console.log(window.innerWidth); // safe — runs only in browser
  }, []);
  return null;
}
How do I fix ReferenceError: document is not defined in Gatsby?

In Gatsby, browser-only code must go into useEffect, componentDidMount, or the gatsby-browser.js file. The Gatsby build step runs in Node.js with no browser globals. The gatsby-browser.js file is guaranteed to only run in the browser.

// gatsby-browser.js — always runs in browser
export const onClientEntry = () => {
  document.body.classList.add('js');
};
How do I fix ReferenceError: document is not defined in Jest?

Add testEnvironment: 'jsdom' to jest.config.js. Jest defaults to the 'node' environment which has no DOM. In Jest 27+, install the separate package: npm install --save-dev jest-environment-jsdom. For a single test file, add the docblock /** @jest-environment jsdom */ at the top.

What is the difference between document is not defined and window is not defined?

They are the same class of error — both document and window are browser-only globals absent from Node.js. window is not defined typically appears when code checks for the browser environment using window, while document is not defined appears when code tries to manipulate the DOM directly. navigator is not defined, localStorage is not defined, and location is not defined are all the same family. The same typeof guard fixes all of them.

Can I use typeof window check inside a React component's render?

Technically yes, but it causes hydration mismatches. The server renders with window === undefined and the client renders with window defined, producing different HTML. React will log a warning. The correct pattern is to initialize state to a neutral value (e.g., null) and update it inside useEffect:

const [width, setWidth] = useState(null); // null on both server and client initial render

useEffect(() => {
  setWidth(window.innerWidth); // updates only in browser after hydration
}, []);
How do I create a reusable isBrowser utility helper?

Create a shared utility to avoid repeating typeof window !== 'undefined' everywhere. Bundlers tree-shake the server path when building for the browser target:

// utils/environment.js
export const isBrowser = typeof window !== 'undefined';
export const isServer  = !isBrowser;

// Usage anywhere in your app
import { isBrowser } from './utils/environment';
if (isBrowser) {
  window.analytics.track('pageview');
}
How do I fix ReferenceError: document is not defined in Astro?

In Astro, never access document or window in the frontmatter (--- section) — it runs on the server. For framework components (React, Vue, Svelte), add a client:load, client:idle, or client:visible directive. For plain JavaScript, place it inside a <script> tag in the template — Astro bundles these for browser execution only.

Related Errors