Skip to main content

6 min read #angular #ssr #hydration #dom #frontend

document is not defined: a survival guide

Turn on SSR and your DOM code starts throwing on the server. afterNextRender is the clean fix — but knowing the four other escape hatches, and which one each situation wants, is what makes SSR boring.

The day a team turns on SSR is the day they learn how much of their codebase secretly assumed a browser. The build passes, the server starts, the first request comes in — ReferenceError: document is not defined. Then window is not defined. Then localStorage. It's whack-a-mole, and every mole is a line of code that was always fine until the rendering moved to Node, where none of those globals exist.

The root misunderstanding: ngAfterViewInit sounds like "the DOM is ready" but means "Angular's view structures are ready" — and that happens on the server too. Lifecycle hooks don't know what a browser is. So the chart init you put there, reasonably, runs in Node and dies.

afterNextRender: browser-only by definition

export class ChartPanel {
  private canvas = viewChild.required<ElementRef>('canvas');

  constructor() {
    afterNextRender(() => {
      // never runs on the server. Not "guarded" — just doesn't run.
      this.chart = new Chart(this.canvas().nativeElement, this.config);
    });
  }
}

afterNextRender runs once, after the next render, in the browser only — on the server it's simply skipped, by contract. That's a different and better thing than wrapping code in a platform check: the API's semantics carry the guarantee, so there's no condition to forget and nothing to get out of sync. Chart libraries, map SDKs, focus management, anything that measures — this is where they live now.

Recurring layout work gets phases

For work that runs after every render — tracking an element's size, syncing a canvas — there's the recurring variant, and its phase option matters more than it looks:

afterEveryRender({
  read: () => {
    this.width = this.el.nativeElement.offsetWidth;  // reads only
  },
  write: () => {
    this.overlay.nativeElement.style.width = this.width + 'px';  // writes only
  },
});

Interleaving DOM reads and writes forces synchronous reflows — the classic layout-thrashing trap, invisible at 60fps on your machine and very visible on a mid-range phone. The phases batch all reads before all writes across every registered callback. The browser-only guarantee is the same as the one-shot version.

The rest of the kit

Not everything fits the render-callback shape, so the kit has more pieces — each with a narrow job:

  • inject(DOCUMENT) — when you need the document object itself (meta tags, title, body classes). Works on both platforms because the server provides an implementation; prefer it over the global, always.
  • isPlatformBrowser(inject(PLATFORM_ID)) — the explicit fork, for when server and browser should do different things rather than browser-only things. If you find these checks multiplying through a service, that's a design smell: split the service in two and provide per platform.
  • inject(REQUEST) — the server-side counterpart people forget exists: during server render you can read the incoming request's cookies and headers. The auth token in a cookie? The server render can know who's logged in. No window needed — the information was in the request all along.
  • For localStorage specifically: it has no server equivalent, period. Read it in afterNextRender, accept that the server renders the logged-out/default state, and let hydration catch up — or move the data to a cookie if the server genuinely needs it.

The rule that ends the whack-a-mole

After the third SSR-broken codebase, I stopped treating this as a bug-fixing exercise and adopted a default: DOM- and browser-API code goes in afterNextRender unless there's a stated reason otherwise. Not because every project will turn on SSR — but because code written that way costs nothing extra, works identically in the browser, and means the day someone does flip the SSR switch (or moves the component under @defer (hydrate on viewport), where the same discipline pays off), nothing happens. The best SSR migration is the one where the moles were never buried in the first place.