Server-side rendering promises fast first paint and crawlable HTML, but it runs your component code in Node.js — an environment with no window, no rendering engine, and no IntersectionObserver, ResizeObserver, or MutationObserver globals. Every framework that supports SSR (Next.js, Nuxt, Angular Universal, SvelteKit, Remix) forces the same decision on every observer-driven component: where exactly does the boundary between server code and client-only code sit, and how do you avoid a hydration mismatch when the two environments render different output for the same component. This guide covers that boundary in full, building on the SSR guard patterns introduced in Core Observer Fundamentals & Browser APIs and the teardown discipline covered in Observer Lifecycle & Memory Management.

Concept Framing

An IntersectionObserver, ResizeObserver, or MutationObserver is a browser API. It is defined on window and backed by the rendering engine's internal layout and paint machinery — there is no equivalent in server-side JavaScript because there is no layout to observe. When a Next.js page runs through getServerSideProps or a React Server Component render pass, or when Nuxt runs Vue's server renderer, your component's top-level code executes once in Node.js to produce an HTML string, and then again in the browser to "hydrate" that HTML into an interactive page.

Any code that references IntersectionObserver (or window, document, navigator) during that first, server-side execution throws immediately, because the identifier does not exist in the global scope. This is different from a null or undefined value — it is a ReferenceError, and it will crash the entire server render unless caught.

The second, more subtle problem is hydration mismatch. React, Vue, and Angular all compare (or assume identical) the server-rendered markup against the client's first render pass before attaching event listeners and enabling reactivity. If your component's initial client render produces different output than the server did — because a client-only check flipped some isVisible state synchronously during render, rather than after mount — the framework detects a mismatch. React logs a hydration warning and may discard and re-render the subtree; Vue warns and patches over the difference; in the worst case, an interactive element briefly reverts to its server-rendered state before snapping to its client state, producing a visible flash.

The diagram below shows the full timeline: server render, HTML sent to the browser, client hydration, and the one safe point after hydration where observer instantiation cannot cause a mismatch.

SSR to Hydration Timeline — Safe Observer Instantiation Point A horizontal timeline with four stages: Server Render (Node.js, no window), HTML Sent to Browser, Client Hydration (React/Vue/Angular attach listeners), and Post-Hydration (client-only hook fires, safe to create an observer). A crimson marker highlights that instantiating an observer during Server Render or during the Hydration comparison pass causes an error or mismatch, while the green marker after hydration is the only safe zone. Server Render Node.js — no window observer() throws here HTML Sent to browser Client Hydration markup comparison mismatch risk here Post-Hydration useEffect / onMounted ✓ safe to instantiate Render output must be identical between Server Render and the client's first pass — observer-driven state must not change what renders until Post-Hydration. Crimson stages: instantiating or branching on observer state here causes a crash or mismatch. Green stage: the only point where creating IntersectionObserver / ResizeObserver / MutationObserver is safe.

Spec / Signature Reference Table

Each framework exposes a different client-only entry point. The table below maps the environment guard or lifecycle hook to when it actually fires relative to hydration.

Framework Client-only mechanism Fires relative to hydration Runs on server?
React (Next.js) useEffect(() => {...}, []) After the DOM commit and hydration comparison complete No
React (Next.js) typeof window !== 'undefined' guard Wherever placed — safe only inside useEffect or event handlers Evaluates on server too, but branch is skipped
Next.js next/dynamic(() => import(...), { ssr: false }) Component is skipped entirely during server render No
Next.js (App Router) 'use client' directive Marks the module as client-only; still hydrates, so guard observer calls inside useEffect Renders on server for the initial HTML unless combined with ssr: false
Vue 3 (Nuxt) onMounted(() => {...}) After the component's DOM is mounted in the browser, post-hydration No
Nuxt <ClientOnly> wrapper component Skips server rendering for its slot content entirely No
Angular Universal isPlatformBrowser(platformId) Runtime check — must be called inside a hook that also might run on the server Evaluates on server; branch guards the call
Angular 16+ afterNextRender(() => {...}) After the next change-detection render pass, guaranteed browser-only No

Step-by-Step Implementation

Step 1 — Guard the module boundary with a typed helper

Never call new IntersectionObserver(...) where the constructor reference itself might be evaluated during SSR. Wrap creation in a helper that returns null when the API is unavailable, and always check for null at the call site.

TypeScript
function createObserverIfSupported(
  callback: IntersectionObserverCallback,
  options: IntersectionObserverInit = {}
): IntersectionObserver | null {
  if (typeof window === 'undefined') return null;
  if (typeof IntersectionObserver === 'undefined') return null;
  return new IntersectionObserver(callback, options);
}
JavaScript
// Plain JS equivalent
function createObserverIfSupported(callback, options = {}) {
  if (typeof window === 'undefined') return null;
  if (typeof IntersectionObserver === 'undefined') return null;
  return new IntersectionObserver(callback, options);
}

Step 2 — Render visible-by-default, then enhance

Ship server-rendered markup that is correct without JavaScript. Lazy-loading, fade-in animation, or measurement should refine that baseline, never gate it. This is the isomorphic fallback pattern: the server output and the client's pre-effect output are identical, so there is nothing for the hydration comparison to flag.

TypeScript
// React — safe: initial state matches server output exactly
function RevealSection({ children }: { children: React.ReactNode }): JSX.Element {
  const ref = useRef<HTMLDivElement>(null);
  // Starts true on both server and client's first render — no mismatch
  const [enhanced, setEnhanced] = useState(true);

  useEffect(() => {
    if (!ref.current) return;
    const observer = createObserverIfSupported(([entry]) => {
      setEnhanced(entry.isIntersecting);
    }, { threshold: 0.1 });
    if (!observer) return; // SSR or unsupported — keep content visible
    observer.observe(ref.current);
    return () => observer.disconnect();
  }, []);

  return (
    <div ref={ref} className={enhanced ? 'visible' : 'fade-out'}>
      {children}
    </div>
  );
}

Step 3 — Defer to the framework's client-only hook

For React and Next.js, useEffect is the boundary — it never runs during server rendering and always runs after the DOM has been committed and hydration has completed.

TypeScript
useEffect(() => {
  const observer = createObserverIfSupported(handleIntersection, { threshold: 0.5 });
  if (!observer || !targetRef.current) return;
  observer.observe(targetRef.current);
  return () => observer.disconnect();
}, []);

For Nuxt, onMounted provides the same guarantee for the Options and Composition APIs:

TypeScript
import { onMounted, onUnmounted, ref } from 'vue';

export function useVisibility(): { target: Ref<HTMLElement | null>; visible: Ref<boolean> } {
  const target = ref<HTMLElement | null>(null);
  const visible = ref(true); // matches server-rendered default

  let observer: IntersectionObserver | null = null;

  onMounted(() => {
    if (!target.value || typeof IntersectionObserver === 'undefined') return;
    observer = new IntersectionObserver(([entry]) => {
      visible.value = entry.isIntersecting;
    }, { threshold: 0.1 });
    observer.observe(target.value);
  });

  onUnmounted(() => observer?.disconnect());

  return { target, visible };
}

For Angular Universal, prefer afterNextRender over a manual isPlatformBrowser check where Angular 16+ is available, since the framework itself enforces the boundary:

TypeScript
import { Component, ElementRef, afterNextRender, inject, OnDestroy } from '@angular/core';

@Component({ selector: 'app-tracked', standalone: true, template: `<div #el><ng-content /></div>` })
export class TrackedComponent implements OnDestroy {
  private el = inject(ElementRef<HTMLElement>);
  private observer?: IntersectionObserver;

  constructor() {
    afterNextRender(() => {
      // Guaranteed to run only in the browser, after the first render
      this.observer = new IntersectionObserver(([entry]) => {
        this.el.nativeElement.classList.toggle('visible', entry.isIntersecting);
      }, { threshold: 0.1 });
      this.observer.observe(this.el.nativeElement);
    });
  }

  ngOnDestroy(): void {
    this.observer?.disconnect();
  }
}

Step 4 — Dynamic import with ssr:false for fully client-only widgets

When a component has no meaningful server-rendered state at all — a resize-driven chart, a canvas that measures itself with ResizeObserver — skip server rendering for that subtree entirely rather than fighting hydration mismatches.

TypeScript
// Next.js — pages router or App Router client component boundary
import dynamic from 'next/dynamic';

const ResizeAwareChart = dynamic(() => import('./ResizeAwareChart'), {
  ssr: false,
  loading: () => <div className="chart-placeholder" aria-hidden="true" />,
});
HTML
<!-- Nuxt — equivalent client-only boundary -->
<ClientOnly>
  <ResizeAwareChart />
  <template #fallback>
    <div class="chart-placeholder" aria-hidden="true" />
  </template>
</ClientOnly>

Configuration Variants

Scenario Recommended approach Trade-off
Content must be crawlable and visible without JS Visible-by-default + progressive enhancement (Step 2) Slight extra state to reconcile after mount
Component has no useful non-JS state (charts, canvases) Dynamic import with ssr:false / <ClientOnly> No SEO value for that subtree; shows a placeholder during load
App Router "use client" component that still needs SSR HTML 'use client' + useEffect guard Renders on the server for markup, but observer logic waits for the client-only hook
Angular Universal, targeting Angular 16+ afterNextRender Requires a recent Angular version; simplest and safest option
Angular Universal, older Angular versions isPlatformBrowser(platformId) inside ngAfterViewInit Manual guard, easy to forget on a new component

Edge Cases & Gotchas

Module-scope instantiation is the most common mistake

Declaring const observer = new IntersectionObserver(...) at the top of a file — outside any function or component — executes that line the moment the module is imported, which happens during the server's module graph resolution in Next.js and Nuxt. This throws before any component even renders. Always create observers inside a function, and only call that function from a client-only hook.

typeof window guards inside render bodies still risk a mismatch

Wrapping an observer call in if (typeof window !== 'undefined') prevents the crash, but if that branch also sets state or changes the JSX output during the render function itself (rather than inside useEffect), the client's first render pass will differ from what the server sent, since window genuinely differs between the two environments. Keep environment checks inside effects, not inline in JSX or component bodies.

ResizeObserver-driven layout can't have a meaningful SSR value

Unlike intersection, which can safely default to "visible" as a fallback, an initial ResizeObserver-measured width has no correct default on the server — there is no layout to measure. Render a sensible fallback width (a CSS-based responsive layout, or a skeleton with aspect-ratio reserved) so layout thrashing and content-jump are avoided while waiting for the first client measurement.

MutationObserver targets must exist before you observe them

MutationObserver.observe(target, options) throws synchronously if target is not a Node. In SSR frameworks, refs are null until after the first client render commits — always null-check the ref inside the client-only hook, not just the API's existence.

Strict Mode double-invocation surfaces SSR bugs faster

React 18 Strict Mode intentionally mounts, unmounts, and remounts effects in development. If your SSR guard is subtly wrong (for example, checking typeof document but not typeof IntersectionObserver), Strict Mode's double-invocation will often surface the bug locally before it reaches a broken production hydration.

Framework Integration Patterns

Next.js App Router — client component with SSR-safe observer

TypeScript
'use client';

import { useEffect, useRef, useState } from 'react';

export function LazySection({ children }: { children: React.ReactNode }): JSX.Element {
  const ref = useRef<HTMLDivElement>(null);
  const [loaded, setLoaded] = useState(false);

  useEffect(() => {
    if (typeof window === 'undefined' || !ref.current) return;
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setLoaded(true);
        observer.disconnect();
      }
    }, { rootMargin: '200px' });
    observer.observe(ref.current);
    return () => observer.disconnect();
  }, []);

  return <div ref={ref}>{loaded ? children : <div className="skeleton" />}</div>;
}

Nuxt 3 — composable used across pages

The React observer hooks pattern and this Nuxt composable share the same shape: a client-only creation point, a reactive result, and teardown wired to the framework's unmount hook. See Vue observer composables for the complete composable library, including useResizeObserver.

Angular Universal — directive with afterNextRender

The Angular observer directives guide extends the afterNextRender pattern shown above into a reusable structural directive for lazy image loading across an entire application.

Debugging Checklist

Use this checklist when an SSR build fails or hydration warnings appear in the console:

  • ReferenceError: IntersectionObserver is not defined at build or server-render time — search for any new IntersectionObserver, new ResizeObserver, or new MutationObserver call outside a useEffect, onMounted, or afterNextRender block, and outside a typeof window guard.
  • Hydration mismatch warning mentioning text or attribute content — check whether an observer-derived boolean or class name is used in the render output before the client-only hook has run. Ensure the pre-effect state matches the server-rendered default exactly.
  • Observer never fires on the client — confirm the ref is non-null at the point the client-only hook runs; in SSR frameworks the DOM node may not exist yet on the very first effect tick if it is inside a Suspense boundary or lazy-loaded chunk.
  • Works in development, fails only in production SSR build — check for tree-shaking or minification renaming window checks incorrectly, or for a dynamic import missing ssr: false that got inlined during a production optimization pass.
  • Flash of unstyled or ungated content before hydration — confirm the server-rendered fallback (Step 2) is the intended default appearance, not an accidental placeholder state that looks broken.
  • Works locally, fails in edge runtime deployments — some edge runtimes (Vercel Edge, Cloudflare Workers) have partial or no DOM globals even for code paths you assumed were server-only; audit every top-level import for browser API usage.

Frequently Asked Questions

Why does IntersectionObserver throw a ReferenceError during server rendering?

Node.js has no browser rendering engine, so globals like IntersectionObserver, ResizeObserver, and window simply do not exist in that runtime. If a component instantiates an observer at module scope or during the render function body, that code executes on the server during the SSR pass and throws ReferenceError: IntersectionObserver is not defined. The fix is to defer instantiation until a client-only lifecycle point.

Does a typeof window check alone make an observer SSR-safe?

It prevents the crash, but placement still matters. A typeof window !== 'undefined' guard inside a function body that only runs client-side (useEffect, onMounted, ngAfterViewInit) is fully safe. The same guard placed directly in a component's render/setup body can still create a hydration mismatch, because the server renders one output (no observer, no state update) and the client's first render pass must match it exactly before the effect runs.

What is a hydration mismatch and how do observers cause one?

A hydration mismatch happens when the HTML the server sent differs from what the client's first render produces, before any effects run. If an observer-driven boolean like isVisible defaults to false on the server but a client-only check flips it to true synchronously during the initial render (instead of inside an effect), React or Vue detects a text or attribute mismatch and either warns, re-renders, or in strict frameworks throws. Keep the first client render identical to the server output, then let the effect update state afterward.

Should I disable SSR entirely for observer-heavy components?

Only for components where the pre-observer content genuinely has no useful server-rendered state, such as a chart that renders exclusively from ResizeObserver-measured dimensions. Dynamic import with ssr:false (Next.js) or a client-only wrapper (Nuxt's <ClientOnly>) skips server rendering for that subtree entirely. For content that should be indexable and visible without JavaScript, prefer rendering it visible-by-default and using the observer only to enhance behavior, not to gate initial visibility.

How does Angular's afterNextRender differ from isPlatformBrowser for observer setup?

isPlatformBrowser(platformId) is a runtime check you place inside any lifecycle hook, including ones that also run during server rendering (ngOnInit), so you must still guard manually. afterNextRender registers a callback that Angular guarantees only executes in the browser, after the next change-detection render pass completes — it is the more idiomatic choice for Angular Universal apps built on Angular 16+, since the framework itself enforces the client-only boundary.


↑ Back to Framework Integration & Observer Adapters