OGE logoOGE

Angular SSR, hydration and zoneless

Every Angular family renders on a server and hydrates in the browser without a mismatch, and none of them needs zone.js. This site is the proof: all of its pages are prerendered, hydrated with event replay and run zoneless.

@angular/ssr withEventReplay zoneless

What renders on the server

The whole component: markup, ARIA attributes, the first page of in-memory data and the initial state of every input. What waits for the browser is what needs one — measuring (virtual-scroll windows sized from the viewport, popup placement, overflow menus), observers and timers. Each claim below is a test:

SSR claims and the tests that prove them
ClaimProven by
Every Angular family renders on a server with no browser globals apps/ssr-smoke/src/server-render.spec.ts — renderApplication per family in plain Node, hydration annotations on, silent console
Components hydrate the server DOM instead of re-rendering it apps/ssr-smoke/src/hydration.spec.ts — every ngh consumed, the server elements survive, no NG05xx
Every page of this site hydrates apps/dev-app-e2e/ssr/hydration.spec.ts — crawls the sitemap; fails on NG05xx, CSP reports or uncaught errors
The suite runs under a strict nonce CSP with Trusted Types apps/dev-app-e2e/ssr/strict-csp.spec.ts — see the CSP guide

Hydration with event replay

Standard Angular setup — OGE needs no provider of its own. Event replay queues the clicks and keys a reader makes before the bundle has hydrated the page.

// app.config.ts — the browser half
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    // adopt the server DOM instead of re-rendering it; clicks and keys made
    // before hydration finishes are queued and replayed
    provideClientHydration(withEventReplay()),
    provideRouter(routes),
  ],
};

The event replay fold

Angular 22.2 has an ordering bug that bites any content projected into a component and rendered outside its host — in OGE, declarative <oge-tab> children of <oge-tab-panel> and <oge-step> children of <oge-stepper>. With withEventReplay() on, the client fails with NG0509 and leaves the view unhydrated. Until Angular fixes it, fold the replay call into its contract script on the server:

// app.config.server.ts — the server half, with the event-replay fold
import { ApplicationConfig, DOCUMENT, inject, mergeApplicationConfig } from '@angular/core';
import { BEFORE_APP_SERIALIZED } from '@angular/platform-server';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';

/**
 * Angular 22.2 inserts the replay bootstrap <script> into <body> after it has
 * computed the hydration annotations, so nodes located by a path from <body>
 * (<oge-tab> projected into <oge-tab-panel>, <oge-step> into <oge-stepper>)
 * end up one sibling off and the client fails with NG0509. Folding the call
 * into the contract script restores the child list the annotations expect.
 */
function foldReplayBootstrap(doc: Document): void {
  const contract = doc.getElementById('ng-event-dispatch-contract');
  if (contract === null) return;
  for (const script of Array.from(doc.querySelectorAll('script'))) {
    if (script === contract || script.hasAttribute('type')) continue;
    const code = script.textContent ?? '';
    if (!code.startsWith('window.__jsaction_bootstrap(')) continue;
    contract.textContent = `${contract.textContent ?? ''}\n${code}`;
    script.remove();
  }
}

export const serverConfig: ApplicationConfig = mergeApplicationConfig(appConfig, {
  providers: [
    provideServerRendering(withRoutes(serverRoutes)),
    {
      provide: BEFORE_APP_SERIALIZED,
      useFactory: () => {
        const doc = inject(DOCUMENT);
        return () => foldReplayBootstrap(doc);
      },
      multi: true,
    },
  ],
});

apps/ssr-smoke/src/hydration.spec.ts pins the upstream bug — it fails once Angular fixes the ordering, which is the cue to delete the fold. If you serve with a hash-based CSP, the fold also changes the inline script's hash: re-derive it from your built HTML.

Ids, locale and time zones

  • Ids. Generated ids (aria-controls, aria-labelledby) may differ between the server and the browser render; Angular hydration re-binds every attribute from the client's values, so they never mismatch. Pass your own id where an outside element must point at a component.
  • Locale. The grid, tree list, pivot, pager and form summaries read LOCALE_ID, the same value on both sides. Set it explicitly — a server whose runtime locale differs from your readers' then cannot drift.
  • Time zones. Scheduler and Gantt layouts that involve "today" assume the server and the browser share a time zone. Pass timeZone where they may not.
// app.config.ts — server and browser must agree on the locale
import { ApplicationConfig, LOCALE_ID } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
};

Browser-only work

The components never touch window, document, localStorage or ResizeObserver while constructing or destroying, and take observers from the element's own window. Your own host code should do the same: render a server default, adjust in afterNextRender.

import { ChangeDetectionStrategy, Component, afterNextRender, signal } from '@angular/core';
import { OgeGrid, OgeColumn } from '@oge-ui/grid';

@Component({
  selector: 'demo-root',
  imports: [OgeGrid, OgeColumn],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <oge-grid [data]="employees" keyField="id" [virtualScroll]="true" [style.height.px]="height()">
      <oge-column field="firstName" caption="First name" />
      <oge-column field="city" caption="City" />
    </oge-grid>
  `,
})
export class Demo {
  readonly employees = [
    { id: 1, firstName: 'Ali', lastName: 'Yılmaz', department: 'Engineering', city: 'İstanbul', salary: 8400, hireDate: '2019-03-11' },
    { id: 2, firstName: 'AyĹźe', lastName: 'Kaya', department: 'Sales', city: 'Ankara', salary: 7200, hireDate: '2020-07-02' },
    { id: 3, firstName: 'Mehmet', lastName: 'Demir', department: 'Engineering', city: 'İzmir', salary: 9100, hireDate: '2018-01-23' },
    { id: 4, firstName: 'Zeynep', lastName: 'Şahin', department: 'Finance', city: 'İstanbul', salary: 6800, hireDate: '2021-11-15' },
    { id: 5, firstName: 'Emre', lastName: 'Çelik', department: 'Support', city: 'Bursa', salary: 5400, hireDate: '2022-05-09' },
  ];

  // the server renders the default height; the browser sizes it after hydration
  readonly height = signal(400);

  constructor() {
    afterNextRender(() => {
      this.height.set(Math.max(300, window.innerHeight - 200));
    });
  }
}

Zoneless

The packages make no NgZone assumptions: signal inputs, OnPush, and state changes that schedule their own render. An Angular 22 app that does not load zone.js is zoneless with nothing to configure — this site has no zone.js installed at all.

// main.ts — Angular 22 bootstraps zoneless when zone.js is not loaded;
// leave "zone.js" out of the polyfills in angular.json and that is all
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';

bootstrapApplication(App, appConfig).catch((error) => console.error(error));
  • Mutating a plain array you bound to [data] in place is not a change the grid can see. Bind a signal and set a new array, call refresh(), or use a DataSource with a changes stream (see Performance).
  • In tests, settle the fixture the zoneless way — detectChanges(), await whenStable(), detectChanges() (see Testing).