AngularAtlased. 22.2

Ch. 12SSR & hydration56 / 75

Server-compatible code

window doesn’t exist on the server: PLATFORM_ID, afterNextRender, DOCUMENT, TransferState, the HTTP cache and PendingTasks.

v22 ~0 min read Official docs

With SSR or prerender, your components run twice: once in Node — on each request or at build time — to produce HTML, and once more in the browser, during hydration. Constructors, field initialisers, ngOnInit, effect(), services, interceptors and guards run on both platforms. Only a few APIs are reserved for the browser: afterNextRender, afterEveryRender, afterRenderEffect and, of course, the user’s events.

On the server there is no window, no localStorage, no layout and no observers. There is a document — emulated by Domino — but only through inject(DOCUMENT). Code written “for the browser” works perfectly in ng serve without SSR and only fails in the server build. Or, worse, it doesn’t fail, but sends wrong HTML: the wrong theme, missing data, every request done twice.

In short
  • There is no window, document or localStorage on the server. Code that needs them runs in afterNextRender.
  • What you loaded on the server reaches the browser through the HTTP transfer cache, so HttpClient does not repeat the request during hydration.
  • The server sends the page only when the app has no pending tasks left (PendingTasks). A timer that never stops keeps the request open.
  • A page that does not exist must answer with status 404, not just display “not found”.
  • Module-level variables are shared by every request on the server. A user’s state belongs in services, not there.
Why it matters
This page is the checklist for code review in a project with SSR: what breaks on the server, where to move the browser code, how not to do the work twice (TransferState and the HTTP cache), when the server decides the page is ready (PendingTasks), and how to avoid state leaking between users. The SSR configuration is in SSR, prerender & render modes, and taking over the HTML in the browser is in Hydration.

What doesn’t exist on the server

The Angular server runs in Node, with a DOM emulated by Domino (included in @angular/platform-server). The important distinction: the browser globals are completely missing and give a ReferenceError, while the document received through DI has structure but no layout. The table is verified on Node 22/24 and on the Domino of @angular/platform-server 22.2.2.

APIOn the serverWhat to use instead
window, document (global), location, historydoesn’t exist: ReferenceError: window is not definedinject(DOCUMENT), Router, Location
localStorage, sessionStoragedon’t existafterNextRender; for what matters at render time, a cookie read from REQUEST
matchMedia, innerWidthmissing on document.defaultView too (the Domino window)CSS media queries; BreakpointObserver from CDK (on the server the queries don’t match); afterNextRender
IntersectionObserver, ResizeObserver, MutationObserver, requestAnimationFramedon’t exist@defer (on viewport); afterNextRender + DestroyRef
getBoundingClientRect(), offsetWidth, scrollHeightDomino elements don’t have them (TypeError or undefined)afterNextRender({ read })
document.cookiethe getter throws Error: NotYetImplementedinject(REQUEST)?.headers.get('cookie')
navigatorexists in Node 21+, but it belongs to the server (userAgent: 'Node.js/22')the headers from REQUEST; isPlatformBrowser
setTimeout, setInterval, fetchexist, but timers don’t stop on their own, and fetch doesn’t accept relative URLsafterNextRender + DestroyRef; HttpClient

What runs where

CodeServerBrowser
field initializers, constructor, ngOnInit, ngOnChangesyesyes
effect(), computed(), the template, pipesyesyes
services, interceptors, guards, resolversyes — new instances on each requestyes
ngOnDestroy, DestroyRef.onDestroyyes — the server destroys the application after renderingyes
afterNextRender, afterEveryRender, afterRenderEffectno (NOOP)yes
event listeners: (click), (input)…no — nobody clicks on the serveryes
@defer without hydrateonly the @placeholderthe triggers start here
LiveWould it run on the server? (simulation of the server environment)
6 errors2 warning3 OK
theme = 'light';

constructor() {
  this.theme = localStorage.getItem('theme') ?? 'light';
}
Error · server (Node + Domino)ReferenceError: localStorage is not defined

Why: The constructor also runs on the server, and in Node there is no localStorage. Even if there were, the server has no access to the user’s storage.

The fix
readonly theme = signal('light');
constructor() {
  afterNextRender(() =>
    this.theme.set(localStorage.getItem('theme') ?? 'light'));
}
// or: a cookie read on the server from inject(REQUEST)
Switch between Browser and “Runs on the server”. Each fragment really runs: on “server”, in a sandbox where the browser globals are missing as in Node, navigator is the Node one, and inject(DOCUMENT) gives a Domino-style document. The error messages are the same as in Node. Only the TypeScript modifiers (private, readonly) are removed before execution.
The rule
No browser API in the constructor, field initializers, ngOnInit or effect(). Everything that touches the DOM, the layout, the storage or a browser library goes into afterNextRender / afterRenderEffect — or behind a service with a separate server implementation.

afterNextRender and the phases

afterNextRender and afterEveryRender (stable since v20, when afterRender was renamed afterEveryRender) register callbacks that run after Angular has finished rendering — only in the browser. On the server they are NOOPs: the callback isn’t even registered. That’s why they are the default place for any code that touches the DOM, the layout or the window APIs. They must be called in an injection context (constructor, field initializer) or with the injector option, and they clean up by themselves when the component is destroyed.

You can give them a function or an object with phases. The phases run in the order earlyRead → write → mixedReadWrite → read, and each one receives the value returned by the previous phase. The separation exists so that Angular can group the reads and writes of the whole application: a layout read after a write forces the browser to recalculate the layout on the spot (layout thrashing). A plain function runs in mixedReadWrite, the phase the documentation asks you to avoid.

PhaseYou mayExample
earlyReadonly reads, before the writesyou measure something a write depends on (custom layout)
writeonly writesstyles, initialising a library
mixedReadWritereads and writes mixedonly if the work can’t be split
readonly reads, after the writesyou measure the final result and put it in a signal
import { Component, ElementRef, afterNextRender, input, viewChild } from '@angular/core';

/** Shrinks the title until it fits on one line. On the server nothing happens. */
@Component({
  selector: 'app-fit-title',
  template: `<h2 #title class="fit">{{ text() }}</h2>`,
})
export class FitTitle {
  readonly text = input.required<string>();
  private readonly title = viewChild.required<ElementRef<HTMLElement>>('title');

  constructor() {
    // Once, after the first render, ONLY in the browser (on the server it's a no-op).
    afterNextRender({
      // 1. earlyRead: read the layout before any write
      earlyRead: () => {
        const el = this.title().nativeElement;
        return Math.min(1, el.clientWidth / el.scrollWidth); // 1 = it fits
      },
      // 2. write: receives the result of the previous phase and only writes
      write: (ratio) => {
        this.title().nativeElement.style.fontSize = `${Math.floor(32 * ratio)}px`;
      },
    });
  }
}
LivePLATFORM_ID and the moment of measuring (real API)
ExpressionHere (live) On the server it would be
inject(PLATFORM_ID)'server''server'
isPlatformBrowser(id)falsefalse
isPlatformServer(id)truetrue
inject(REQUEST)nullRequest (url, headers, cookie)
inject(RESPONSE_INIT)null{ status, headers }
The measured componentwidth now (afterEveryRender, read phase): 0 px
  1. +0.1 msconstructorisConnected=false, width=0px, children=0, binding=null
  2. +0.3 msngOnInitisConnected=true, width=0px, children=0, binding=""

On the server only the constructor and ngOnInit rows would run (no layout: the widths wouldn’t exist); afterNextRender and afterEveryRender are ignored.

The table shows the real values of this page. Press “Remount the component” and read the log: in the constructor the element isn’t on the page and has no content; in ngOnInit it is attached, but the template bindings aren’t applied yet (and on the server it would have no layout); only in the afterNextRender phases is everything rendered. Move the slider: afterEveryRender re-measures after each render.

afterEveryRender or afterRenderEffect

afterEveryRender runs after every render of the application, including when something changed in a completely different component. If your work depends on some signals, afterRenderEffect is the cheaper option: an effect that runs after rendering only when its dependencies change (and again only in the browser). The difference from effect(): effect also runs on the server and has no read/write phases.

import { Component, ElementRef, afterRenderEffect, input, viewChild } from '@angular/core';

@Component({
  selector: 'app-chat-log',
  template: `
    <div #scroller class="messages">
      @for (m of messages(); track m.id) { <p>{{ m.text }}</p> }
    </div>
  `,
})
export class ChatLog {
  readonly messages = input.required<readonly { id: number; text: string }[]>();
  private readonly scroller = viewChild.required<ElementRef<HTMLElement>>('scroller');

  constructor() {
    // ✗ would run after EVERY render of the application, even if the list hasn't changed:
    // afterEveryRender({ write: () => this.scrollToBottom() });

    // ✓ runs after rendering only when the signals it reads have changed (still only in the browser)
    afterRenderEffect({
      write: () => {
        this.messages(); // dependency
        this.scrollToBottom();
      },
    });
  }

  private scrollToBottom(): void {
    const el = this.scroller().nativeElement;
    el.scrollTop = el.scrollHeight;
  }
}
Hydration isn’t guaranteed
The API documentation is explicit: components are not guaranteed to be hydrated when an afterNextRender callback runs. Read and write the DOM carefully and don’t move nodes that Angular renders — otherwise the mismatch errors described in Hydration appear.

Libraries that touch window

Many libraries (maps, charts, rich-text editors) access window right at import. The server crashes when it loads the module, before any component runs, and no check in the constructor saves you any more. The solution is a dynamic import() inside afterNextRender; as a bonus, the library ends up in a separate chunk. For whole components, @defer does the same declaratively.

import { Component, DestroyRef, ElementRef, afterNextRender, inject, viewChild } from '@angular/core';
// ✗ import * as L from 'leaflet';  — the module touches window as soon as it is imported:
//    the server crashes when it loads the file, before any component runs.

@Component({
  selector: 'app-store-map',
  template: `<div #host class="map" aria-label="Store map"></div>`,
  // a fixed height reserves the space in the server HTML too → no layout shift
  styles: `.map { height: 320px; }`,
})
export class StoreMap {
  private readonly host = viewChild.required<ElementRef<HTMLElement>>('host');
  private readonly destroyRef = inject(DestroyRef); // inject() only synchronously, not after await

  constructor() {
    afterNextRender(async () => {
      const L = await import('leaflet'); // separate chunk, downloaded only in the browser
      if (this.destroyRef.destroyed) return; // the user left in the meantime
      const map = L.map(this.host().nativeElement).setView([44.4268, 26.1025], 12);
      this.destroyRef.onDestroy(() => map.remove());
    });
  }
}

If the library rewrites the DOM that Angular rendered, hydration complains about mismatches. ngSkipHydration on the host component stops hydration only for it — it’s a patch, not a solution.

DOCUMENT, PLATFORM_ID and platform providers

inject(DOCUMENT) — from @angular/core since v20; @angular/common re-exports it — gives you the current document on any platform. On the server it is the Domino document of the request: you can read and write the structure (title, <meta>, <link rel="canonical">, attributes on <html>), and the changes end up in the HTML that is sent. You have no layout, cookies or matchMedia.

import { DOCUMENT, Service, inject } from '@angular/core';

@Service()
export class CanonicalLink {
  private readonly document = inject(DOCUMENT); // on the server: the Domino document of the request

  /** Also runs on the server: the tag ends up in the HTML that crawlers see. */
  set(url: string): void {
    let link = this.document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
    if (!link) {
      link = this.document.createElement('link');
      link.rel = 'canonical';
      this.document.head.appendChild(link);
    }
    link.href = url;
  }
}

// For <title> and <meta> there are already SSR-safe services in @angular/platform-browser:
//   inject(Title).setTitle('Phone X — Store');
//   inject(Meta).updateTag({ name: 'description', content: '…' });

inject(PLATFORM_ID) returns 'browser' or 'server', and you test it with isPlatformBrowser / isPlatformServer from @angular/common. It works, but the official guide recommends platform providers instead of runtime checks, for two reasons: the checks multiply through all the code, and used in an @if in the template they produce different content on the server and in the browser, that is a layout shift and hydration errors. Keep it for side effects that don’t change what is rendered.

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

@Service()
export class Analytics {
  // PLATFORM_ID is 'browser' or 'server'; don't compare it by hand, use the helpers from @angular/common
  private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

  track(event: string): void {
    if (!this.isBrowser) return; // ok: a side effect that doesn't change what is rendered
    navigator.sendBeacon('/collect', JSON.stringify({ event, at: Date.now() }));
  }
}

// ✗ NOT like this — server and browser render different content → layout shift and hydration errors:
//   @if (isBrowser) { <app-recommendations /> } @else { <p>Loading…</p> }
// ✓ use @defer (on viewport) or afterNextRender for what only makes sense in the browser.

When the platforms need different behaviour, the clean approach is a contract with two implementations: the components inject the abstract class, app.config.ts provides the browser implementation, and app.config.server.ts overrides it. No if in the business code — the same principle as in Providers and Injection tokens.

import { Injectable } from '@angular/core';

// The contract: components depend only on the abstract class.
export abstract class AnalyticsService {
  abstract trackEvent(name: string): void;
}

@Injectable()
export class BrowserAnalyticsService implements AnalyticsService {
  trackEvent(name: string): void {
    navigator.sendBeacon('/collect', name); // browser API — allowed here
  }
}

@Injectable()
export class ServerAnalyticsService implements AnalyticsService {
  trackEvent(_name: string): void {
    // on the server: nothing (or a log), no browser APIs
  }
}

TransferState

Without anything special, an SSR page loads its data twice: once on the server, to render the HTML, and again in the browser, because during hydration the components start from zero, with an empty state. The result: an extra request and the content from the server briefly replaced by a skeleton.

TransferState is a key → value dictionary that travels inside the HTML. On the server you write into it; before serialisation Angular calls toJson() and puts the result in a <script id="ng-state" type="application/json"> at the end of body — only if it isn’t empty. In the browser, on the first injection, TransferState reads the script back. The keys are created with makeStateKey<T>('…'), which also binds the type of the value. The full API: get(key, default), set, remove, hasKey, isEmpty, onSerialize, toJson.

import { isPlatformServer } from '@angular/common';
import { PLATFORM_ID, Service, TransferState, inject, makeStateKey } from '@angular/core';

export interface Article { slug: string; title: string; body: string }

// Typed key: TransferState knows that an Article sits under it
const articleKey = (slug: string) => makeStateKey<Article>(`article:${slug}`);

@Service()
export class ArticleStore {
  private readonly cms = inject(CmsClient); // SDK with its own fetch → does NOT go through HttpClient
  private readonly state = inject(TransferState);
  private readonly isServer = isPlatformServer(inject(PLATFORM_ID));

  async load(slug: string): Promise<Article> {
    const key = articleKey(slug);
    const cached = this.state.get(key, null);
    if (cached) {
      this.state.remove(key); // used only once: later navigations ask for fresh data
      return cached;
    }
    const article = await this.cms.getArticle(slug);
    if (this.isServer) this.state.set(key, article); // ends up in <script id="ng-state">
    return article;
  }
}
LiveTransferState: one request instead of two (real API)

HTML sent by the server

Nothing yet. Start the rendering on the server.

What the user sees

The page hasn’t loaded yet.
0requests to /api/products/3
Step 1 makes a real GET (through the simulated backend) and writes into TransferState; the JSON in the script is exactly the result of toJson(). Step 2 creates a new TransferState from a document containing that script, exactly as the browser does at bootstrap. Untick “use TransferState” and repeat: the second request and the skeleton appear.

The values go through JSON.stringify / JSON.parse: a Date becomes a string, a Map and a Set become {}, class instances lose their methods. Transfer simple DTOs (see Interfaces & models). Anything you put there is visible in the page source and makes the HTML heavier: the demo shows how many characters it adds.

You don’t write this for HttpClient
GETs through HttpClient are transferred automatically (next section). Manual TransferState is for data that doesn’t go through HttpClient: SDKs (Firebase, a CMS), a direct fetch, expensive calculations done once on the server. For resources, from v22 there is also resource({ id }).

The HTTP transfer cache

provideClientHydration() starts, by default, an interceptor that applies exactly the pattern above to HttpClient. On the server it saves the responses in TransferState, under a key computed as a SHA-256 hash of the method, the response type, the URL, the body and the parameters. In the browser it serves them from the cache — but only until the application becomes stable for the first time; after that everything goes to the network, as usual. httpResource goes through HttpClient, so it benefits automatically, and so do your interceptors (see HTTP).

In 22.2.2 only GET and HEAD requests that meet these conditions are cached by default:

  • they have no Authorization, Proxy-Authorization or Cookie headers;
  • they aren’t sent with withCredentials or with credentials: 'include' / 'same-origin';
  • they have no Cache-Control with no-store, no-cache or private (neither the request nor the response), and don’t use cache: 'no-store' / 'no-cache';
  • their response doesn’t contain Set-Cookie.
import { ApplicationConfig } from '@angular/core';
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    // The transfer cache is ON by default with provideClientHydration();
    // withHttpTransferCacheOptions only tunes it.
    provideClientHydration(
      withHttpTransferCacheOptions({
        // which response headers are saved (default: none)
        includeHeaders: ['ETag'],
        // false → the request is not cached (per-user data, data that changes often)
        filter: (req) => !req.url.includes('/api/cart'),
        // read-only POSTs (e.g. GraphQL) — only if they are idempotent
        includePostRequests: true,
        // default false: requests with Authorization / Proxy-Authorization / Cookie are skipped
        includeRequestsWithAuthHeaders: false,
      }),
    ),
  ],
};
OptionDefaultWhen to change it
filter(req)all eligible requestsyou remove per-user or very volatile endpoints (false = not cached)
includeHeadersno headerthe browser needs ETag, Link… Never sensitive headers
includePostRequestsfalsea POST used for reads (GraphQL), only if it is idempotent
includeRequestsWithAuthHeadersfalsethe auth header doesn’t change the response (a public key)
includeRequestsWithCredentialsfalseresponses with credentials are surely public
includeNonCacheableRequestsfalseyou consciously ignore Cache-Control / Set-Cookie (rare)

Resources that don’t use HttpClient v22

From v22, resource() and rxResource() accept an id. The value resolved on the server is written into TransferState under that id, and in the browser the resource starts directly in the resolved state, without calling the loader again — again only while the hydration cache is active, that is until the first stability, and only with provideClientHydration() in the configuration.

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

@Component({ selector: 'app-weather', template: `{{ forecast.value()?.summary }}` })
export class Weather {
  private readonly sdk = inject(WeatherSdk); // not HttpClient → the HTTP cache doesn't see it

  // v22: with an id, the value resolved on the server is put in TransferState and reused during hydration.
  // The id must be unique on the page and identical on the server and in the browser.
  protected readonly forecast = resource({
    id: 'weather-bucharest',
    loader: () => this.sdk.forecast('Bucharest'),
  });
}

Different origins on the server and in the browser

If the server talks to the API on an internal address and the browser on the public one, the URLs differ and the cache keys no longer match. HTTP_TRANSFER_CACHE_ORIGIN_MAP (from @angular/common/http) tells the server to compute the key as if it had used the public origin. It is declared only in the server configuration: if Angular finds it in the browser, it throws the error NG02803.

// api-base-url.ts — in the browser: the public origin
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL', {
  factory: () => 'https://api.magazin.ro',
});

// app.config.server.ts (fragment) — on the server: the internal network, without going through the load balancer
providers: [
  provideServerRendering(withRoutes(serverRoutes)),
  { provide: API_BASE_URL, useValue: 'http://catalog.internal:8080' },
  // the same paths on both origins → the keys in the transfer cache match
  // (only in the server configuration; in the browser Angular throws an error if it finds it)
  {
    provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP,
    useValue: { 'http://catalog.internal:8080': 'https://api.magazin.ro' },
  },
];

One more detail from v22, when HttpClient uses the fetch backend by default: on the server, each response body is limited to 1 MB (otherwise the error NG02825). The limit is changed with provideServerRendering({ maxResponseBodySize }, withRoutes(…)), but the guide recommends keeping it small: large downloads have no place in server rendering.

Stability and PendingTasks

The server doesn’t know when your page is “ready”. Its rule is simple: after bootstrap it waits for ApplicationRef.whenStable() and only then serialises the DOM and TransferState. The application is stable when there are no more tasks in PendingTasks. Angular adds tasks by itself for the HttpClient requests in progress, for router navigation and for the loading resources (resource, httpResource). With Zone.js, any timer or microtask in the Angular zone also keeps the application unstable.

Zoneless applications — the default since v21 — no longer see your timers and promises. If you load data with your own SDK, with fetch or after a setTimeout, the server serialises before the data arrives: the HTML leaves empty, and the browser asks for it again. Make them visible with PendingTasks (stable since v20; the run method and the pendingUntilEvent operator are still developer preview):

import { Component, PendingTasks, inject, signal } from '@angular/core';
import { pendingUntilEvent, toSignal } from '@angular/core/rxjs-interop';

@Component({ selector: 'app-price', template: `{{ price() }} · {{ stock() }}` })
export class Price {
  private readonly tasks = inject(PendingTasks);
  private readonly sdk = inject(PricingSdk); // its own promises, not HttpClient
  readonly price = signal<string | null>(null);

  // 1. run(): stability waits until the async function finishes (developer preview)
  constructor() {
    this.tasks.run(async () => this.price.set(await this.sdk.priceOf(3)));
  }

  // 2. add(): manual control — DON'T forget the cleanup, otherwise the server never stops waiting
  async refresh(): Promise<void> {
    const done = this.tasks.add();
    try {
      this.price.set(await this.sdk.priceOf(3));
    } finally {
      done();
    }
  }

  // 3. pendingUntilEvent(): unstable until the first emission / complete / error (developer preview)
  readonly stock = toSignal(this.sdk.stock$(3).pipe(pendingUntilEvent()), { initialValue: 0 });
}
LiveWhen does the server serialise? (real whenStable, in the browser)
appRef.isStable: true
What the server would send (snapshot at whenStable()):
<p class="price"></p>
Both buttons load a price in 1.2 seconds and then wait for appRef.whenStable() — exactly what the server does before serialisation. Without PendingTasks, whenStable resolves at once and the “HTML” leaves empty; with PendingTasks.run(), it waits for the data. Watch the isStable pill too.
A task that never ends = a request that never ends
Server rendering has no timeout: if a task in PendingTasks is never removed (an add() without a finally, an Observable that never emits with pendingUntilEvent), the response hangs until the proxy cuts it off. With Zone.js, a setInterval started at initialisation does the same. Polling, websockets and recurring timers have no place on the server: start them in afterNextRender.
import { provideStabilityDebugging } from '@angular/core';

// Active by default in dev mode when you use provideClientHydration().
// Manually: for SSR without hydration or in a production build, temporarily.
bootstrapApplication(App, {
  providers: [provideStabilityDebugging()],
});
// If the application doesn't become stable within ~9 s, it logs to the console the PendingTasks
// that are keeping it busy. With Zone.js, also import 'zone.js/plugins/task-tracking' for stack traces.

Request, response and the 404 status

@angular/core exports three tokens for the current request. @angular/ssr fills them only during rendering on request (RenderMode.Server); in the browser, at prerender, at build and when extracting the routes in dev they are null — so always use ?..

TokenContainsUsed for
REQUESTthe Request from the Web API: url, headerscookies, Accept-Language, an absolute URL for canonical / Open Graph
RESPONSE_INITa mutable ResponseInit: status, statusText, headersdynamic 404 / 410, headers computed at rendering
REQUEST_CONTEXTunknown: the second argument of handle(request, context)tenant, request id, the user already authenticated by a middleware
import { Component, RESPONSE_INIT, inject } from '@angular/core';

@Component({
  selector: 'app-product-missing',
  template: `<h1>This product no longer exists</h1><a href="/produse">See the catalog</a>`,
})
export class ProductMissing {
  constructor() {
    // null in the browser, during prerender (SSG) and at build time — that's why the ?.
    const response = inject(RESPONSE_INIT);
    if (response) response.status = 404; // otherwise: a "soft 404" gets indexed as a real page
  }
}

Why the status matters: a “Product doesn’t exist” page served with 200 is a soft 404, which search engines can index as a real page. The status of the ServerRoute covers the static cases (the ** route); for “the id doesn’t exist in the database” you need RESPONSE_INIT. It works because @angular/ssr builds the response only after whenStable(), so until then you can change the status.

import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID, REQUEST, Service, inject } from '@angular/core';

@Service()
export class RequestInfo {
  private readonly request = inject(REQUEST); // Request from the Web API; null in the browser / SSG
  private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

  /** Preferred language: the header on the server, navigator in the browser. */
  preferredLanguage(): string {
    if (this.request) return this.request.headers.get('accept-language')?.split(',')[0].trim() || 'en';
    // no request: browser OR prerender. DON'T test "typeof navigator" — Node 21+ has it.
    return this.isBrowser ? navigator.language : 'en';
  }

  cookie(name: string): string | null {
    const raw = this.request?.headers.get('cookie') ?? '';
    const match = raw.split('; ').find((c) => c.startsWith(`${name}=`));
    return match ? decodeURIComponent(match.slice(name.length + 1)) : null;
  }
}

Relative and absolute URLs

On the server there is no “current page” against which a relative URL can be resolved. HttpClient solves the problem by itself: @angular/platform-server adds an interceptor that turns /api/… into an absolute URL, based on the request URL and <base href>. A direct fetch() and the SDKs that use it don’t benefit from this.

// ✓ HttpClient on the server: the relative URL is resolved against the URL of the current request
this.http.get('/api/products');          // → https://magazin.ro/api/products

// ✗ fetch() directly (or an SDK that uses it) — Node has no "current page":
await fetch('/api/products');            // TypeError: Failed to parse URL from /api/products

// ✓ if you must use fetch(): build the absolute URL from the request
const base = inject(REQUEST)?.url ?? inject(DOCUMENT).location.href;
await fetch(new URL('/api/products', base));

Precisely because the URLs are built from the request’s host, AngularNodeAppEngine checks the hostname against allowedHosts (in angular.json, at build.options.security.allowedHosts, or through the NG_ALLOWED_HOSTS variable) and answers 400 for an unknown host. It is the protection against SSRF: configure it with your domains.

State leaking between requests

In the browser, a JavaScript module is evaluated once per tab, that is per user. On the server, the same module is evaluated once per process and serves thousands of requests. Any module-level variable becomes state shared by all users. A provider with useValue has the same problem: the object is created when the module loads and lives until the server restarts — the SSR guide says so explicitly and recommends useFactory.

Services are safe: on each request, @angular/ssr creates a new platform and a new application, so a new root injector. Each user gets their own instances, destroyed after the response.

// ✗ Module-level state: the file is evaluated ONCE per Node process,
//    so the variables are shared by ALL requests (and all users).
let currentUser: User | null = null;          // user B sees user A
const cart = new Map<number, number>();       // the cart grows with each request

// ✗ The same with useValue: the object is created once, when the module loads
{ provide: CART_STATE, useValue: { items: [] } }

// ✓ A service: on the server each request bootstraps a new application,
//    so it gets a new instance (which is destroyed after the response).
@Service()
export class CartStore {
  private readonly items = signal<CartItem[]>([]);
  readonly count = computed(() => this.items().length);
}

// ✓ Or useFactory: runs for each application that is created
{ provide: CART_STATE, useFactory: () => ({ items: [] }) }
The “harmless” cache next to a service
const cache = new Map() at module level looks like an optimisation. On the server it is a memory leak (it grows endlessly) and, if the key doesn’t include the user, a data leak between accounts. If you want a cache between requests, make it explicit — in server.ts or at the CDN — with keys, a TTL and a size limit.

Case study: the theme

The theme is the classic example: the preference lives in localStorage, and the CSS depends on an attribute on <html>. This site is an SPA, without SSR, so its ThemeStore is correct here. But it is a good exercise: if we started SSR tomorrow, what would break?

// This site's ThemeStore (fragment). The site is an SPA, so it's correct HERE.
@Service()
export class ThemeStore {
  private readonly document = inject(DOCUMENT);
  // ✗ on the server: defaultView is the Domino window, which has NO matchMedia → TypeError
  private readonly media = this.document.defaultView?.matchMedia('(prefers-color-scheme: dark)');

  // readStoredMode() reads localStorage in a try/catch: on the server it doesn't crash,
  //    but it always returns 'system' — the server can't know what the user chose.
  readonly mode = signal<ThemeMode>(readStoredMode());

  constructor() {
    this.media?.addEventListener('change', (e) => this.systemDark.set(e.matches));
    effect(() => {
      // ✓ DOM through DOCUMENT: works on the server too
      // localStorage in try/catch: on the server it is silently ignored
    });
  }
}

Two problems. The first: the line with matchMedia throws when the service is created, and ?. doesn’t help, because on the server defaultView exists (the defaultView?.matchMedia(…) fragment in the first demo reproduces exactly this). The second: the server always renders the system theme, because it can’t see localStorage. The SSR-safe variant moves the preference into a cookie, which the server reads from REQUEST, and the effect() writes the attribute on the server too:

import { isPlatformBrowser } from '@angular/common';
import {
  DOCUMENT, DestroyRef, PLATFORM_ID, REQUEST, Service,
  afterNextRender, computed, effect, inject, signal,
} from '@angular/core';

export type ThemeMode = 'system' | 'light' | 'dark';
const COOKIE = 'atlas-theme';

@Service()
export class ThemeStore {
  private readonly document = inject(DOCUMENT);
  private readonly request = inject(REQUEST);
  private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

  // The server doesn't know whether the system is dark: for 'system' the CSS decides
  // (prefers-color-scheme), and the signal is corrected in the browser.
  private readonly systemDark = signal(false);
  readonly mode = signal<ThemeMode>(this.readCookie());
  readonly isDark = computed(
    () => this.mode() === 'dark' || (this.mode() === 'system' && this.systemDark()),
  );

  constructor() {
    const destroyRef = inject(DestroyRef);
    afterNextRender(() => {
      const media = matchMedia('(prefers-color-scheme: dark)'); // browser only
      const onChange = (e: MediaQueryListEvent) => this.systemDark.set(e.matches);
      this.systemDark.set(media.matches);
      media.addEventListener('change', onChange);
      destroyRef.onDestroy(() => media.removeEventListener('change', onChange));
    });

    effect(() => {
      const mode = this.mode();
      // also runs on the server → <html data-theme="dark"> is already in the HTML, no flash
      const root = this.document.documentElement;
      if (mode === 'system') root.removeAttribute('data-theme');
      else root.setAttribute('data-theme', mode);
      // cookie (not localStorage): the server receives it on the next request
      if (this.isBrowser) {
        this.document.cookie = `${COOKIE}=${mode}; path=/; max-age=31536000; samesite=lax`;
      }
    });
  }

  private readCookie(): ThemeMode {
    const raw = this.request?.headers.get('cookie') // server: the header of the request
      ?? (this.isBrowser ? this.document.cookie : ''); // browser (on the server, cookie throws)
    const value = raw
      .split('; ')
      .find((c) => c.startsWith(`${COOKIE}=`))
      ?.slice(COOKIE.length + 1);
    return value === 'light' || value === 'dark' ? value : 'system';
  }
}

The alternative without a cookie: you keep localStorage and an inline script in index.html that sets data-theme before the first paint — exactly what this site does. It works with SSR too (the server sends HTML without the attribute, the script puts it in place in the browser before the application), but the server still doesn’t know the theme, so it can’t choose, for example, the right images or theme-color.

<!-- The alternative without a cookie (what this site does): an inline script in <head>,
     which runs before the first paint, in any render mode (CSR, SSR, prerender). -->
<script>
  try {
    var t = localStorage.getItem('atlas-theme');
    if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
  } catch (e) {}
</script>

When YES, when NO

SSR-safe code doesn’t mean if (isBrowser) everywhere, but each thing placed in the right spot.

Use
  • afterNextRender / afterRenderEffect for everything that touches the DOM, the layout, the storage or a browser library.
  • inject(DOCUMENT), Title, Meta for everything that must be in the HTML from the server (SEO, sharing).
  • Platform providers in app.config.server.ts when the server and the browser do different things.
  • The default HTTP cache for public GETs; a manual TransferState or resource({ id }) for data that doesn’t go through HttpClient.
  • PendingTasks or resource() for any async work whose result must be in the HTML.
  • RESPONSE_INIT for dynamic statuses, REQUEST for cookies and language.
Avoid (and what to put instead)
  • isPlatformBrowser in an @if in the template → @defer or afterNextRender, with the same HTML on both platforms.
  • typeof window sprinkled through components → a service with a separate server implementation.
  • TransferState for large lists or per-user data on pages cached at the CDN → load them in the browser.
  • PendingTasks for polling, websockets, analytics → start them in afterNextRender; the server shouldn’t wait for them. A complete example is on the WebSockets page.
  • HTTP cache for sensitive responses → filter or transferCache: false.
  • ngSkipHydration as a permanent solution → fix the direct DOM manipulation.

Common pitfalls

effect() also runs on the server
Many people treat effect() as a “browser hook”. It isn’t: it runs at change detection, so on the server too. An effect that writes to localStorage, calls matchMedia or starts an observer breaks at rendering. What belongs to the browser goes into afterRenderEffect or under afterNextRender.
?. is not a platform guard
document.defaultView?.matchMedia(…) and typeof navigator !== 'undefined' look safe, but on the server defaultView exists (the Domino window, without matchMedia), and Node 21+ has a navigator. The first throws a TypeError, the second gives the wrong answer. Use afterNextRender or isPlatformBrowser.
  • Measurements in the constructor or ngOnInit: they break on the server (there is no layout). In the browser, the constructor measures 0, and in ngOnInit the bindings and the @if / @for blocks of the component aren’t rendered yet. Fix: afterNextRender({ read }).
  • Timers and subscriptions started in the constructor: with Zone.js they block serialisation, zoneless ones keep running. Fix: start them in afterNextRender and stop them with DestroyRef.
  • pendingTasks.add() without finally: an exception and the request never finish. Fix: try/finally or run().
  • Untracked async in zoneless: fetch, SDKs, setTimeout → empty HTML + one more request in the browser. Fix: resource() or PendingTasks.
  • Generic TransferState keys ('data') or a static resource({ id }) in a component used twice on the page: the values overwrite each other. Fix: keys that include the parameters (article:${slug}), unique ids per page.
  • The key isn’t removed after reading: a later navigation on the same page gets the old data from the HTML. Fix: state.remove(key) after the first use.
  • HTTP_TRANSFER_CACHE_ORIGIN_MAP in app.config.ts: error NG02803 in the browser. Fix: only in app.config.server.ts.
  • A relative fetch('/api/…') on the server: TypeError: Failed to parse URL. Fix: HttpClient or an absolute URL built from REQUEST.
  • A “not found” page with status 200: a soft 404 in Google. Fix: RESPONSE_INIT or status: 404 on the ** route.
  • Values that differ between server and browser: Date.now(), Math.random(), dates formatted in the server’s time zone (often UTC). The text changes at hydration. Fix: generate them once on the server and transfer them, or display them only in the browser.
  • Module-level state and useValue with objects: shared between users. Fix: services or useFactory.

Cheat sheet

You want to…Use
touch the DOM / the layout once, after renderingafterNextRender({ earlyRead, write, read })
synchronise the DOM with some signalsafterRenderEffect(() => …)
measure after every renderafterEveryRender({ read })
load a library that touches windowawait import('lib') in afterNextRender, or @defer
read / write title, meta, canonicalinject(DOCUMENT), Title, Meta
a different implementation on the serveran abstract class + a provider in app.config.server.ts (mergeApplicationConfig)
a side effect only in the browserisPlatformBrowser(inject(PLATFORM_ID))
not to repeat the GETs in the browserprovideClientHydration() (default), withHttpTransferCacheOptions(…)
take a request out of the cachefilter or { transferCache: false }
transfer data that doesn’t come through HttpClientmakeStateKey<T>() + TransferState, or resource({ id }) (v22)
different API origins on the server and in the browserHTTP_TRANSFER_CACHE_ORIGIN_MAP, only on the server
the server to wait for an asyncresource(), PendingTasks.run() / add(), pendingUntilEvent()
find out why the application doesn’t become stableprovideStabilityDebugging()
read cookies / headersinject(REQUEST)?.headers.get('cookie')
answer with a 404inject(RESPONSE_INIT) → status = 404, or status: 404 on the ** route
receive context from server.tshandle(req, ctx) → inject(REQUEST_CONTEXT)
per-user state on the serverservices or useFactory — never module variables

Ch. 12 · 0 of 3 complete

Finished this lesson?

Mark it and it shows up on your map: in the contents and on the home page.

Marks itself as done once you have read the whole lesson.

  1. SSR, prerender & render modes
  2. Hydration: event replay & incremental
  3. Server-compatible code
See your whole map

All 75 pages · type to filter

  • 01What's new in Angular 21 & 22All the important changes in v21 and v22, briefly, with a link to the page that explains each one.Start
  • 01Your first app, step by stepA reading list built from an empty component to a finished app — signals, @for, inputs and outputs, a service and a form, one step at a time.Start
  • 01Quick decisions“Where do I put this?”, “Signal or Observable?”, “Shared or feature?” — short answers to everyday questions.Start
  • 01Angular interview questions60 real interview questions for junior, mid and senior, with the answer to give, the follow-up and the red flags. Plus a mock interview.Start
  • 02Standalone & bootstrapHow an app starts without NgModule: bootstrapApplication, app.config.ts, imports in the component.Modern fundamentals
  • 02Data binding & eventsInterpolation, property and attribute bindings, events with $event and key filters, and how [( )] is built — what goes into the DOM and what comes back.Modern fundamentals
  • 02Control flow: @if, @for, @switchThe block syntax that replaces *ngIf/*ngFor/ngSwitch, with track, @empty and exhaustive checks.Modern fundamentals
  • 02@let and the new template expressionsLocal variables with @let, arrow functions, spread, regex, instanceof, comments inside tags.Modern fundamentals
  • 02@defer — lazy loading in templatesLoad heavy components only when needed: triggers, prefetch, placeholder, loading, error.Modern fundamentals
  • 02Class & style bindingsWhy [class.x] and [style.x] replaced ngClass and ngStyle (with automatic migration from v21).Modern fundamentals
  • 02Component styles & encapsulation:host, the three encapsulation modes, theming with CSS variables, and why ::ng-deep is a last resort.Modern fundamentals
  • 02Pipes: when and howPure vs impure pipes, custom pipes, and how you choose between a pipe, computed() and a method.Modern fundamentals
  • 03signal() and computed()The reactive primitives: writable state, derived state, equality, untracked and immutability rules.Signals & reactivity
  • 03effect() — when YES, when NOSide effects synchronised with signals: localStorage, logging, imperative APIs. And their traps.Signals & reactivity
  • 03linkedSignal() — editable derived stateA signal that resets when its source changes, but that you can also set manually.Signals & reactivity
  • 03resource, rxResource, httpResourceAsync data as signals: status, value, error, reload, automatic cancellation. Stable since v22.Signals & reactivity
  • 03debounced() — delayed signalsThe “debounce” version of a signal, without RxJS. Perfect for search.Signals & reactivity
  • 03Lists: search, filter, sort, paginateA chain of computed signals that recalculates only what changed, with the filters kept in the URL so a link reopens the same view.Signals & reactivity
  • 03Observables from zeroWhat an Observable is, subscribe and unsubscribe, the operators you use every week, cold vs hot — and when a signal is enough.Signals & reactivity
  • 03Signals ↔ RxJStoSignal, toObservable, takeUntilDestroyed, outputFromObservable — and when RxJS remains the right choice.Signals & reactivity
  • 04input() and output()Inputs as signals (required, transform, alias) and typed outputs. Data down, events up.Components: communication
  • 04model() — two-way bindingBanana-in-a-box [( )] with a single API. When to use it, and when input + output is better.Components: communication
  • 04viewChild & contentChildQueries as signals: viewChild, viewChildren, contentChild, contentChildren, the read option.Components: communication
  • 04Lifecycle & DestroyRefWhich hooks still matter in the signals era, afterNextRender, DestroyRef and typed ngOnChanges.Components: communication
  • 04Content projectionng-content with select, multiple slots, default content and the trap of conditional projection.Components: communication
  • 04ng-template & ngTemplateOutletTemplates as values: render props in Angular, typed context and customising components.Components: communication
  • 05Smart vs DumbContainer vs presentational: who holds the state, who only displays, and how to split them properly.Component design
  • 05Generic components <T>A correctly typed DataTable<T> or Select<T>, with templates for cells.Component design
  • 05Dynamic componentsNgComponentOutlet, createComponent with inputBinding/outputBinding, and a dashboard built from config.Component design
  • 05Directives & host compositionAttribute directives, the host: {} metadata and the Directive Composition API (hostDirectives).Component design
  • 05Custom form controlsFormValueControl (Signal Forms) vs ControlValueAccessor: a star rating, in both variants.Component design
  • 05Change detectionOnPush is the default from v22, zone.js is gone from v21. What triggers rendering and how not to get burned.Component design
  • 06inject() & @Service()The new @Service() decorator from v22, inject() vs the constructor, and the injection context.Dependency Injection
  • 06Providers & the injector hierarchyRoot vs route vs component, useClass/useValue/useFactory/useExisting, and the optional/self/skipSelf modifiers.Dependency Injection
  • 06InjectionToken & provideX()Typed configuration, provideFeature() functions, makeEnvironmentProviders and initializers.Dependency Injection
  • 06injectAsync() — lazy servicesLoad a heavy service only when you need it, with prefetch on idle.Dependency Injection
  • 07Folder structurecore / shared / features, what goes into each, and what a feature looks like inside.Application architecture
  • 07Shared vs featureWhen to move a component into shared and (especially) when NOT to. A concrete checklist.Application architecture
  • 07Interfaces & modelsInline interface or separate file? DTO vs domain model, type vs interface, where to put them.Application architecture
  • 07State management with signalsSimple signal stores, facades, local vs global state, and when you need NgRx.Application architecture
  • 07Style guide & namingThe current official conventions: file names without suffixes, protected, readonly, member order.Application architecture
  • 07Barrels, aliases & boundariesindex.ts, path aliases, a feature’s public API and dependency rules that can be checked automatically.Application architecture
  • 07Errors: @boundary & ErrorHandlerThe new @boundary block from v22.2, the global ErrorHandler, HTTP errors and errors from resource.Application architecture
  • 08Modern routing & lazy loadingloadComponent, child routes, parameters as inputs and parameter inheritance (the new default in v22).Routing
  • 08Guards & resolverscanActivate, canMatch, canDeactivate and resolve as functions, with redirects via UrlTree / RedirectCommand.Routing
  • 09Signal Forms — the basicsForms built on signals: form(), [formField], schema-based validation. Stable since v22.Forms
  • 09Signal Forms — advancedCustom and cross-field validation, async/HTTP with debounce, dynamic lists, disabled/hidden, reusable schemas.Forms
  • 09Typed Reactive FormsTyped FormGroup, nonNullable, FormArray — and how they coexist with Signal Forms.Forms
  • 10HttpClient, interceptors & the API layerHttpClient without setup (v21), Fetch by default (v22), functional interceptors and an API layer with DTO mapping.HTTP & data
  • 11Performance — a checklistWhat really matters: OnPush + signals, track, @defer, lazy routes, images, bundles and hydration.Quality & performance
  • 11Testing with VitestVitest is the default runner from v21. TestBed, components with signals, services, HTTP and harnesses.Quality & performance
  • 11DevTools: look inside your appChrome DevTools and Angular DevTools step by step: what each panel shows, with exercises that work on this site.Quality & performance
  • 11Angular AriaHeadless, accessible components that you style however you like: tabs, accordion, menu, listbox...Quality & performance
  • 12SSR, prerender & render modesWhen to render on the server, when at build time and when only in the browser: RenderMode per route, app.routes.server.ts and the real costs.SSR & hydration
  • 12Hydration: event replay & incrementalHow the browser takes over server HTML without re-rendering it: full hydration, event replay, @defer (hydrate on …) and mismatch errors.SSR & hydration
  • 12Server-compatible codewindow doesn’t exist on the server: PLATFORM_ID, afterNextRender, DOCUMENT, TransferState, the HTTP cache and PendingTasks.SSR & hydration
  • 13Micro frontendsWhen it is worth splitting an app into several deployments and when it is just complexity: Native Federation, Module Federation, iframes, Web Components.Architecture at scale
  • 13Web Components with Angular ElementscreateCustomElement: an Angular component used as an HTML tag anywhere, plus communication between apps through events and BroadcastChannel.Architecture at scale
  • 13Monorepo, libraries & boundariesA workspace with several apps and libraries: ng generate library, Nx, secondary entry points, publishing and dependency rules.Architecture at scale
  • 13Hexagonal architecture (ports & adapters)A domain without Angular, ports as abstract classes and interchangeable adapters: HTTP, localStorage or in-memory, without touching the UI.Architecture at scale
  • 14Signals under the hoodThe reactive graph: producers and consumers, push-pull, why computed is lazy and glitch-free, the diamond problem and when effects run.Advanced reactivity
  • 14Advanced RxJS: higher-order operatorsswitchMap, mergeMap, concatMap and exhaustMap on a live diagram, plus retry with backoff, shareReplay and how to avoid leaks.Advanced reactivity
  • 14NgRx SignalStoreLarge-scale state management on signals: withState, withComputed, withMethods, rxMethod, entities and hooks — and when a simple store is enough.Advanced reactivity
  • 14State machines with signalsNo more impossible states: a typed machine on signals, with transitions, guards and a live state diagram.Advanced reactivity
  • 15WebSockets: real time that survives the networkA typed socket as signals: reconnect with backoff and jitter, heartbeat, an outbox while offline, and SSR safety.Real time & offline
  • 15Optimistic updates & rollbackAn instant UI that stays honest: snapshot and rollback, out-of-order responses, idempotency keys.Real time & offline
  • 15Offline-first: Service Worker & cachingThe Angular Service Worker: caching strategies, update prompts and an outbox for changes made offline.Real time & offline
  • 16Web WorkersHeavy computation without blocking the UI: a real worker in the page, message passing and when it is not worth it.Advanced UI & performance
  • 16Huge lists: virtual scroll100,000 rows without lag: CDK virtual scroll, itemSize, trackBy and the alternatives (pagination, infinite scroll).Advanced UI & performance
  • 16Modern animations & View Transitionsanimate.enter and animate.leave instead of the deprecated @angular/animations, CSS transitions and View Transitions on navigation.Advanced UI & performance
  • 16Memory leaks: finding and fixing themSubscriptions, timers and listeners that outlive their component — how to spot them in DevTools and close them.Advanced UI & performance
  • 17Security: XSS, sanitisation & CSPWhat Angular does automatically against XSS, when bypassSecurityTrust becomes dangerous, plus CSP with nonce, Trusted Types and XSRF.Security & globalisation
  • 17Authentication: tokens & refreshAn interceptor that attaches the token, refreshes once on 401 and replays the queued requests — plus where you keep the token.Security & globalisation
  • 17OAuth 2.1 & OpenID Connect with PKCEThe authorization code flow with PKCE, step by step: verifier and challenge, state and nonce, the ID token, and why a BFF is safer.Security & globalisation
  • 17Internationalisation (i18n)$localize, the i18n attribute, ICU plural/select, a build per language vs runtime translations, and locale-based formatting.Security & globalisation