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.
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:
| Claim | Proven 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 ownidwhere 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
timeZonewhere 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, callrefresh(), or use aDataSourcewith achangesstream (see Performance). - In tests, settle the fixture the zoneless way —
detectChanges(),await whenStable(),detectChanges()(see Testing).