skip to content

In Angular, why inject the DOCUMENT token instead of using the global document, and what happens to changes made through it during SSR?

level: middleimportance: should knowfreq 44%

answer

  1. a DI token for the document
  2. server has its own DOM
  3. head changes land in HTML
  4. no layout on the server

basics

~20 s

DOCUMENT gives the platform's document through DI: the real one in the browser and a per-request server DOM during SSR. Changes made through it, such as head tags, are serialized into the HTML, while the global document does not exist on the server.

solid answer

~40 s

`DOCUMENT` is an injection token, exported from `@angular/core` since v20, that resolves to the document of the current platform. In the browser that is the real `document`. During SSR, Angular's server platform builds a DOM for each render from the app's HTML, and `DOCUMENT` points at that. The global `document` does not exist in Node, so code that uses it crashes, while `inject(DOCUMENT)` works on both sides. Anything you change through it on the server, such as a `<link rel="canonical">`, a `lang` attribute or a class on the root element, ends up in the HTML the server sends. The server DOM has no layout engine, so measurements are meaningless there. It is also easy to replace in tests. For titles and meta tags, Angular's `Title` and `Meta` services already use it.

go deeper

for a junior

Remember to inject DOCUMENT instead of using the global document, because the global does not exist on the server.

for a middle

Explain that the server renders into a per-request DOM and that changes through DOCUMENT become part of the served HTML.

for a senior

Write head-management code that is idempotent across server and browser, and keep direct DOM changes out of hydrated component views.

for a principal

Decide which page-level concerns, such as canonical links and language, must be server-rendered for crawlers, and centralise them in one service.

## What the token is `DOCUMENT` is an Angular **injection token** that stands for "the document this application is rendering into". Since v20 it is exported from `@angular/core`, and it is still re-exported from `@angular/common` for older imports. You get it with `inject(DOCUMENT)`. What it resolves to depends on the platform: | Platform | What `inject(DOCUMENT)` returns | |---|---| | Browser | The real `window.document` | | Server (SSR or prerender) | A DOM built for this render from the app's HTML, using a JavaScript DOM implementation | | Unit tests | Whatever the test platform provides, and you can override it | ## Why not use the global document - **It does not exist on the server.** Node has no global `document`, so `document.querySelector(...)` in a service throws during SSR. - **It is the wrong document anyway.** Even if a global were patched in, the server renders many requests; each render has its own DOM, and only the injected one is the page being built. - **It hides a dependency.** Injecting `DOCUMENT` makes the dependency visible and replaceable in tests. ## What happens to changes on the server On the server, Angular renders into the injected DOM and then serializes that DOM as the HTML response. Changes made through `DOCUMENT` during the render therefore **become part of the page**: 1. A service appends `<link rel="canonical" href="...">` to `head`. 2. The render finishes and the app becomes stable. 3. Angular serializes the document, including the new link, and sends it. That is why head management is done through the token. `Title` and `Meta`, Angular's services for the page title and meta tags, are built on it, so their output is in the server HTML and visible to crawlers without running JavaScript. ```ts import { Injectable, DOCUMENT, inject } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class CanonicalLink { private doc = inject(DOCUMENT); set(href: string) { let link = this.doc.head.querySelector<HTMLLinkElement>('link[rel="canonical"]'); if (!link) { link = this.doc.createElement('link'); link.rel = 'canonical'; this.doc.head.appendChild(link); } link.href = href; } } ``` The lookup before creating matters: the browser boots on top of the server's HTML, so without it a second `link` would be added on the client. ## Limits of the server DOM - **No layout.** Nothing is laid out on the server, so sizes, positions and scroll values have no meaning. Measure in `afterNextRender()` in the browser. - **No browser APIs.** The server document is a DOM tree, not a `window`, so `defaultView`-based features and browser storage are not available through it. - **Changes inside the app's own view.** Direct DOM changes under components that Angular hydrates can disturb hydration. Prefer templates and bindings for content inside the app, and use `DOCUMENT` for the head, the root element and things outside the component tree. ## Other things that go through the token Several common tasks look like they need `window` or the global `document`, but have an injected path: - Setting `lang` or a theme class on `<html>`: `inject(DOCUMENT).documentElement`. - Reading the current URL: the router or `Location`, not `document.location`. - Creating a `<script>` or `<style>` element for the head: `inject(DOCUMENT).createElement(...)`, remembering the browser will run the same code again after boot. - Listening for document-level keyboard shortcuts: register the listener on the injected document inside `afterNextRender()`, so it is only attached in the browser, and remove it on destroy. ## When to reach for something else - Page title and meta tags: `Title` and `Meta`. - Rendering elements inside a component: templates, `Renderer2`, or `viewChild` in the browser. - Browser-only work such as measuring or focusing: `afterNextRender()`. ## Why interviewers ask it It checks whether a candidate knows that SSR renders into a real, per-request DOM that becomes the response, and that injected platform abstractions are what make the same code valid in both places.

  • Why does code that adds a link tag through DOCUMENT sometimes produce two tags after hydration?
    The browser boots on the server's HTML, which already contains the tag, and then runs the same code again. If the code always appends, the browser adds a second copy. Look up the existing element first and update it, or use `Meta`, which finds existing tags by attribute.
  • Can you measure an element through the injected DOCUMENT on the server?
    No. The server DOM has no layout engine, so layout measurements are either unavailable or carry no real information. Measure in `afterNextRender()`, which only runs in the browser after rendering.

saying these in an interview costs you the question

  • DOCUMENT is just an alias for the global document on every platform.
  • Changes made through DOCUMENT on the server are thrown away.
  • The server DOM can measure element sizes accurately.
  • DOCUMENT must still be imported from @angular/common only.
  • Appending head tags without checking first is safe after hydration.