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
ngOnInitruns 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.
| Hook | Runs on server? | Frequency | Typical use |
|---|---|---|---|
afterNextRender | No | Once, after the next render | One-time DOM setup, widget init, measuring layout |
afterEveryRender | No | After every render | Syncing 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 callwindow/document/localStoragethere unguarded. - Reach for
afterNextRenderfor one-time browser-only setup and DOM measurement instead of sprinklingisPlatformBrowserchecks everywhere. - Use
isPlatformBrowser(inject(PLATFORM_ID))when you need a conditional branch rather than a deferred callback. - Inject the
DOCUMENTtoken and useRenderer2for DOM work so it stays portable across server and browser. - Avoid
setTimeout/requestAnimationFrameas 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.