Skip to content
Angular ng ssr 4 min read

SSR Pitfalls & Browser APIs

When Angular renders your app on the server, there is no window, no document, no localStorage, and no DOM. Any code that reaches for those APIs during component construction or in lifecycle hooks like ngOnInit will throw a ReferenceError on the server and crash the render. The fix is to recognize which code is browser-only and defer it until the browser actually takes over. This page covers the modern tools Angular gives you — isPlatformBrowser, afterNextRender, and afterEveryRender — so your SSR app renders cleanly on both platforms.

Why browser APIs break SSR

During server-side rendering, your components execute inside a Node.js process. The global objects you take for granted in the browser simply do not exist there. The most common offenders are window, document, navigator, localStorage, sessionStorage, and IntersectionObserver. Touching any of them at module load time, in a constructor, or in ngOnInit runs on the server and fails.

// BROKEN: runs on the server during SSR
@Component({ selector: 'app-theme', template: '...' })
export class ThemeComponent implements OnInit {
  ngOnInit() {
    const saved = localStorage.getItem('theme'); // ReferenceError on server
    document.body.classList.add(saved ?? 'light');
  }
}

Output:

ERROR ReferenceError: localStorage is not defined
    at ThemeComponent.ngOnInit (theme.component.ts:6:23)
    at ... (server render)

The render does not silently degrade — the whole route fails to produce HTML, so the user gets an error page or a blank shell. Always assume ngOnInit runs on the server.

Guarding code with isPlatformBrowser

The most explicit guard is isPlatformBrowser, which inspects the injected PLATFORM_ID token. Wrap any browser-only branch in it so the server skips the code entirely.

import { Component, OnInit, PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Component({ selector: 'app-theme', template: '...' })
export class ThemeComponent implements OnInit {
  private readonly platformId = inject(PLATFORM_ID);

  ngOnInit() {
    if (isPlatformBrowser(this.platformId)) {
      const saved = localStorage.getItem('theme') ?? 'light';
      document.body.classList.add(saved);
    }
  }
}

There is a matching isPlatformServer for the rare case where you want server-only logic. Use isPlatformBrowser for the common case: the server falls through, and the browser runs the real code after hydration.

Deferring with afterNextRender

isPlatformBrowser works, but it scatters guards through your code. For DOM measurement and one-time browser setup, the cleaner tool is afterNextRender. Its callback only ever runs in the browser, after the next render cycle completes and the DOM is live — never on the server. This makes it the idiomatic place for reading element sizes, initializing third-party widgets, or attaching IntersectionObserver.

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

@Component({ selector: 'app-chart', template: '<canvas #canvas></canvas>' })
export class ChartComponent {
  private readonly host = inject(ElementRef<HTMLElement>);

  constructor() {
    afterNextRender(() => {
      // Safe: DOM exists, browser only
      const width = this.host.nativeElement.offsetWidth;
      console.log('chart width', width);
    });
  }
}

Output:

chart width 640

Because afterNextRender is registered in the constructor (an injection context) but executes later in the browser, you avoid both the server crash and the hydration mismatch that comes from reading layout too early.

afterNextRender vs afterEveryRender

Angular ships two render callbacks. Use the one that matches how often you need to run.

HookRuns on server?FrequencyTypical use
afterNextRenderNoOnce, after the next renderOne-time DOM setup, widget init, measuring layout
afterEveryRenderNoAfter every renderSyncing imperative DOM state on each change
import { afterEveryRender, inject, ElementRef } from '@angular/core';

constructor() {
  const el = inject(ElementRef<HTMLElement>);
  afterEveryRender(() => {
    el.nativeElement.scrollTop = el.nativeElement.scrollHeight; // keep pinned to bottom
  });
}

Both hooks run outside Angular’s change detection and only fire in the browser. Do not put change-detection-triggering work in afterEveryRender — it can cause render loops.

Injecting DOCUMENT instead of using the global

Even when you legitimately need the document, prefer injecting the DOCUMENT token over the global. On the server, Angular provides a Domino-backed document, so common manipulations stay portable.

import { DOCUMENT } from '@angular/common';
import { Component, inject, Renderer2 } from '@angular/core';

@Component({ selector: 'app-meta', template: '' })
export class MetaComponent {
  private readonly doc = inject(DOCUMENT);
  private readonly renderer = inject(Renderer2);

  setLang(lang: string) {
    this.renderer.setAttribute(this.doc.documentElement, 'lang', lang);
  }
}

Use Renderer2 for DOM writes rather than touching nativeElement directly — it abstracts the platform and is safe to call during server rendering.

Best Practices

  • Treat constructor, ngOnInit, and field initializers as code that runs on the server; never call window/document/localStorage there unguarded.
  • Reach for afterNextRender for one-time browser-only setup and DOM measurement instead of sprinkling isPlatformBrowser checks everywhere.
  • Use isPlatformBrowser(inject(PLATFORM_ID)) when you need a conditional branch rather than a deferred callback.
  • Inject the DOCUMENT token and use Renderer2 for DOM work so it stays portable across server and browser.
  • Avoid setTimeout/requestAnimationFrame as a hack to “wait for the browser” — they still register on the server; use the render hooks designed for it.
  • Keep render callbacks free of change-detection work to prevent infinite render loops, especially in afterEveryRender.
Last updated June 14, 2026
Was this helpful?