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.
ReferenceError: document is not definedReferenceError: window is not definedReferenceError: navigator is not definedReferenceError: localStorage is not definedReferenceError: location is not definedReferenceError: sessionStorage is not definedReferenceError: 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
| Cause | Why 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;
}
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>
);
}
'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; }
}
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
},
});
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:
-
Read the full stack trace. The first frame inside
node_modulesis the library accessing the global. The frame above it (in your code) shows theimportorrequire()that triggered the load. -
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/serveror/ssrentry points. -
Search the library source for
document,window, ornavigatoroutside 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. -
Try importing the problematic module inside a
useEffector with a dynamicimport()to confirm it is the source:useEffect(() => { import('suspect-library').then(({ doThing }) => doThing()); }, []); -
Check whether the library's
package.jsonhas 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. -
For Next.js, enable verbose logging with
NODE_OPTIONS='--trace-warnings' next devto see which module triggers the warning before theReferenceError. -
For Gatsby, run
gatsby buildlocally instead ofgatsby develop— the build step runs SSR and will reproduce the error even ifdevelopworks.
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
| Framework | Recommended 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
ReferenceError: window is not defined— same cause;windowis another browser-only global absent from Node.jsReferenceError: navigator is not defined—navigator.userAgentornavigator.languageaccessed in Node.jsReferenceError: localStorage is not defined— Web Storage API accessed outside a browser contextReferenceError: location is not defined—window.locationor barelocationaccessed during SSRTypeError: Cannot read properties of undefined— accessing a property on a value that isundefined, often after a failed polyfill or stubERR_REQUIRE_ESM— thrown when usingrequire()for a pure ESM package, common alongside browser-only library migration issues