DEV Community

Ali
Ali

Posted on • Originally published at aelm.dev on

document is not defined: a survival guide

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);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  },
});
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)