The ReferenceError: IntersectionObserver is not defined crash is one of the most common build-time failures in Next.js apps that use lazy loading, scroll animations, or viewport analytics — and it always comes down to one root cause: code that touches the browser API before the framework has confirmed you're in a browser.
The Exact Error
The crash typically surfaces in one of two ways. During next dev or next build, the terminal shows a stack trace like:
ReferenceError: IntersectionObserver is not defined
at new VisibilityTracker (./components/VisibilityTracker.tsx:12:20)
at renderToReadableStream
...
Error occurred prerendering page "/products/[id]"
Or, if the crash happens during static generation specifically:
> Build error occurred
Error: Export encountered an error on /products/[id]/page: /products/123, exiting the build.
ReferenceError: IntersectionObserver is not defined
Both are the same underlying failure: a line of code referencing the global IntersectionObserver executed while Next.js was running your component in Node.js, not in a browser.
Root Cause
Next.js renders every page on the server first — either at request time (server-side rendering) or ahead of time (static generation) — to produce the initial HTML sent to the browser. That server render executes in Node.js, which has no window, no document, and no IntersectionObserver constructor. Your component's function body runs during that pass exactly as it will in the browser, unless you explicitly gate the observer-touching code behind a client-only boundary.
The two most common patterns that trigger this crash are module-scope instantiation and unguarded render-time instantiation:
// BAD — module scope: runs the instant this file is imported, including on the server
const sharedObserver = new IntersectionObserver(handleEntries, { threshold: 0.1 });
export function VisibilityTracker({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}
// BAD — inside the component body, executed on every render including the server pass
export function VisibilityTracker({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const observer = new IntersectionObserver((entries) => {
// ...
});
// Missing: this must be inside useEffect
if (ref.current) observer.observe(ref.current);
return <div ref={ref}>{children}</div>;
}
Both examples reference IntersectionObserver in code paths that Next.js executes during server rendering. The fix is always the same shape: move the reference behind a boundary the server never crosses. The Core Observer Fundamentals guide's SSR guidance and the full SSR & Hydration Observer Safety guide cover this boundary across every major framework — this page focuses specifically on the four fixes that resolve it in Next.js.
Fix 1 — Move Instantiation Into useEffect
useEffect never runs during server rendering — React guarantees it only executes in the browser, after the DOM has committed. This is the correct fix for the vast majority of cases, including the two broken examples above.
'use client';
import { useEffect, useRef, useState } from 'react';
export function VisibilityTracker({ children }: { children: React.ReactNode }): JSX.Element {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(true); // matches server-rendered default
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(
([entry]) => setIsVisible(entry.isIntersecting),
{ threshold: 0.1 }
);
observer.observe(ref.current);
return () => observer.disconnect(); // teardown on unmount
}, []);
return <div ref={ref} className={isVisible ? 'visible' : 'hidden'}>{children}</div>;
}
// Plain JS equivalent
'use client';
import { useEffect, useRef, useState } from 'react';
export function VisibilityTracker({ children }) {
const ref = useRef(null);
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(([entry]) => setIsVisible(entry.isIntersecting), { threshold: 0.1 });
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return <div ref={ref} className={isVisible ? 'visible' : 'hidden'}>{children}</div>;
}
Note the initial useState(true) default — it matches whatever the server rendered, so there is no hydration mismatch before the effect runs.
Fix 2 — Add a typeof window Guard
If the observer creation must happen somewhere other than an effect (for example, inside a utility function called from multiple places, some of which run server-side), add an explicit guard at the point of instantiation:
function safeObserve(
target: Element,
callback: IntersectionObserverCallback,
options: IntersectionObserverInit = {}
): (() => void) | undefined {
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
return undefined; // no-op on the server
}
const observer = new IntersectionObserver(callback, options);
observer.observe(target);
return () => observer.disconnect();
}
This guard is a safety net, not a substitute for Fix 1 — always prefer calling safeObserve from inside useEffect so the guard is redundant in practice rather than load-bearing.
Fix 3 — Dynamic Import with ssr:false
If the entire component has no useful server-rendered output — for example, a component whose only job is to measure and react to scroll position — skip server rendering for it entirely:
import dynamic from 'next/dynamic';
const VisibilityTracker = dynamic(
() => import('./VisibilityTracker'),
{
ssr: false,
loading: () => <div className="tracker-placeholder" aria-hidden="true" />,
}
);
export default function Page(): JSX.Element {
return (
<main>
<VisibilityTracker>
<p>Content revealed on scroll.</p>
</VisibilityTracker>
</main>
);
}
With ssr: false, Next.js never executes VisibilityTracker's code during the server render pass — the IntersectionObserver reference is never evaluated in Node.js at all. The trade-off is that this subtree renders nothing (or only the loading placeholder) in the initial HTML response, which matters if the content needs to be visible to search engines or to users with JavaScript disabled.
Fix 4 — Confirm 'use client' Is Present, Then Still Guard
Adding 'use client' to the top of a file tells Next.js's compiler that the module belongs to the client bundle and may use hooks like useState and useEffect. It is necessary for Fix 1 to work in the App Router, but it does not by itself prevent server execution — Next.js still renders client components on the server to produce the initial HTML unless that subtree is also wrapped with dynamic(..., { ssr: false }). Combine 'use client' with Fix 1's useEffect boundary; do not rely on the directive alone.
'use client'; // required for useEffect/useState, but does NOT skip server rendering
import { useEffect, useRef } from 'react';
export function LazyImage({ src, alt }: { src: string; alt: string }): JSX.Element {
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
if (!imgRef.current) return;
// Safe: this callback body never runs on the server, regardless of 'use client'
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && imgRef.current) {
imgRef.current.src = src;
observer.disconnect();
}
}, { rootMargin: '200px' });
observer.observe(imgRef.current);
return () => observer.disconnect();
}, [src]);
return <img ref={imgRef} alt={alt} loading="lazy" />;
}
Which Fix to Choose
| Situation | Use |
|---|---|
| Component has meaningful server-rendered content plus observer-driven enhancement | Fix 1 — useEffect |
| A shared utility function is called from both server and client code paths | Fix 2 — typeof window guard, as a safety net around Fix 1 |
| Component has no useful non-JS output (charts, canvases, scroll-only widgets) | Fix 3 — dynamic(..., { ssr: false }) |
| App Router client component that needs hooks at all | Fix 4 — 'use client' combined with Fix 1 |
Verification
After applying a fix, confirm the crash is gone and no hydration warning has replaced it:
- Run a clean production build.
rm -rf .next && npm run build— this forces Next.js to re-run static generation for every eligible route, which is where this error most often surfaces even whennext devlooked fine. - Check the build output for prerendering errors. A successful build lists every static route without an "Error occurred prerendering" message.
- Start the production server and open DevTools console.
npm run start, then load the affected page and check for React hydration warnings (Text content does not match server-rendered HTMLor similar). - Disable JavaScript and reload. With DevTools' "Disable JavaScript" option on, confirm the page still shows sensible content — this validates that Fix 1's initial state (or Fix 3's placeholder) is an acceptable non-enhanced baseline.
- Throttle to a slow network and watch for a flash of incorrect state. A visible flip between the server-rendered default and the client's observer-driven state right after hydration usually means the initial
useStatevalue does not match what the server rendered — revisit Fix 1's default value.
For the broader set of guard patterns this fix draws on — including Nuxt's onMounted and Angular's afterNextRender equivalents — see the full SSR & Hydration Observer Safety guide.
FAQ
Why does this error only appear in Next.js and not in a plain client-side React app?
A plain client-side React app (built with Vite or Create React App) only ever runs your component code in the browser, where IntersectionObserver always exists. Next.js additionally executes your component functions in Node.js during the server render pass — for the initial HTML response and during static generation — and Node.js has no IntersectionObserver global at all, so the same line of code that works fine in a browser-only app throws in Next.js.
Does adding 'use client' to a component fix the ReferenceError by itself?
Not by itself. 'use client' marks a component as part of the client bundle and enables hooks like useState and useEffect, but Next.js still renders client components on the server for the initial HTML response unless you also opt out with dynamic(..., { ssr: false }). You still need to move the IntersectionObserver call inside useEffect, or guard it with typeof window, even in a 'use client' file.
Why does the error sometimes only appear during next build and not next dev?
next build performs static generation for eligible pages, which runs your component code in Node.js at build time to produce prerendered HTML, exercising the server code path more thoroughly than next dev's on-demand compilation in some configurations. A component that only crashes at build time almost always has an IntersectionObserver call reachable during static generation that isn't hit during your local dev testing flow.
Related
- SSR & Hydration Observer Safety — parent guide covering the full client-only boundary across frameworks
- React Observer Hooks — building a reusable
useIntersectionObserverhook - Browser Compatibility & Polyfills — feature-detection patterns for unsupported environments
- Preventing Memory Leaks in Long-Running Observers — teardown discipline once the observer is running
↑ Back to SSR & Hydration Observer Safety