Fix: Angular SSR Not Working — Hydration Failing, Window Not Defined, or Build Errors
Part of: React & Frontend Errors
Quick Answer
How to fix Angular Server-Side Rendering issues — @angular/ssr setup, hydration, platform detection, transfer state, route-level rendering, and deployment configuration.
The Problem
Angular SSR renders but hydration fails with warnings:
Angular hydration expected a text node but found <div>Or the server crashes:
ReferenceError: window is not defined
ReferenceError: document is not definedOr SSR returns a blank page:
The server responds with HTML but the content is emptyWhy This Happens
Angular SSR renders Angular components on the server using Node.js. Since Angular 17 it has been packaged as @angular/ssr — the same engine that used to ship as Angular Universal, now part of the framework and integrated with the ng CLI.
Browser APIs do not exist on the server. window, document, localStorage, navigator, IntersectionObserver, and the rest of the browser globals are undefined in Node.js. Code that accesses them during component construction or ngOnInit crashes the server render, and the failure surfaces as a 500 with a stack trace pointing into your component. The fix is platform detection: isPlatformBrowser(inject(PLATFORM_ID)) guards browser-only code, and afterNextRender() runs a callback only after the component has hydrated in the browser. afterNextRender is the Angular 17+ idiom — older guides recommended ngAfterViewInit with platform checks, which still works but is more verbose.
Hydration requires the server and client to render the same DOM. Angular’s hydration (full hydration since v16, with event replay since v18) walks the server HTML and adopts it instead of throwing it away and re-rendering. If the server-rendered output and the client’s first render disagree — different conditional branches, different list ordering, time-dependent content like Date.now() or random IDs, mismatched *ngIf based on a flag that is false on the server and true on the client — Angular logs a hydration warning and may fall back to a full re-render. The warnings are easy to ignore in development and break Core Web Vitals in production.
Third-party libraries that access the DOM directly need handling. Chart libraries, map libraries, rich-text editors, and code-mirror-style components often call into document at module load. Importing them at the top of a server-rendered component crashes the server. Either lazy-load them inside afterNextRender (so they only ever load in the browser), or mark the route as client-rendered via RenderMode.Client in the server routes config.
Transfer state prevents duplicate API calls. Without it, the server fetches data and renders HTML, the client receives the HTML, and then the client makes the same API call again because it does not know the server already did. Angular 17+ ships automatic transfer state for HttpClient when you use provideHttpClient(withFetch()); the response is serialized into the HTML and the client uses it instead of re-fetching.
Version History: Universal to @angular/ssr
Angular Universal was the original SSR library for Angular, first released around Angular 4 (2017). It was a separate package (@nguniversal/express-engine and friends), shipped its own CLI commands, and required custom build configuration in angular.json. The DX was rough: hydration was destructive (the server HTML was thrown away and re-rendered on the client), the build produced separate server and browser bundles that were easy to misconfigure, and the documentation lived in a parallel repository to Angular itself.
Angular 16 (May 2023) introduced non-destructive hydration as a developer preview. For the first time, Angular could pick up the server-rendered DOM and adopt it instead of replacing it, which fixed the visual flicker and the “double render” cost. The feature was opt-in via provideClientHydration() and required the application to be free of common hydration pitfalls (direct DOM manipulation, mismatched conditional rendering).
Angular 17 (November 2023) merged Universal into the framework as @angular/ssr. The package now ships under the official @angular scope, the CLI integrates it directly (ng new --ssr, ng add @angular/ssr), and the server build is configured inside the normal angular.json build target. The same release graduated hydration to stable, shipped the Standalone Component APIs that pair naturally with the new functional providers (provideClientHydration, provideHttpClient), and introduced the new control-flow syntax (@if, @for, @switch) which produces hydration-friendly output.
Angular 18 (May 2024) added event replay — events that fired during the time between page paint and hydration are now queued and replayed, so a user who clicks a button before Angular has hydrated does not lose the interaction. Enable it via provideClientHydration(withEventReplay()).
Angular 19 (late 2024) introduced incremental hydration with @defer blocks, letting you hydrate sections of the page on intersection, interaction, or viewport visibility. That is the most recent significant SSR feature at the time of writing.
If you are on Angular 16 or earlier, you are still in the @nguniversal world. The migration to @angular/ssr in Angular 17 is mechanical (ng add @angular/ssr in an Angular 17 project handles most of it) but not transparent: imports change, the server entry file is regenerated, and the route-level rendering API (RenderMode.Prerender | Server | Client) does not exist on the old package. If you are following a tutorial that imports from @nguniversal/express-engine, you are reading documentation for the legacy package.
Fix 1: Enable SSR in Angular 17+
# New project with SSR
ng new my-app --ssr
# Add SSR to existing project
ng add @angular/ssr// angular.json — SSR is configured automatically
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"server": "src/main.server.ts",
"prerender": true,
"ssr": {
"entry": "server.ts"
}
}
}
}
}
}
}// src/app/app.config.ts — client config with hydration
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideClientHydration(withEventReplay()), // Enable hydration
provideHttpClient(withFetch()),
],
};// src/app/app.config.server.ts — server config
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { provideServerRoutesConfig } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
provideServerRoutesConfig(serverRoutes),
],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);Fix 2: Platform Detection
import { Component, PLATFORM_ID, Inject, inject, afterNextRender } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
@Component({
selector: 'app-chart',
template: `
@if (isBrowser) {
<div #chartContainer></div>
} @else {
<div class="chart-placeholder">Loading chart...</div>
}
`,
})
export class ChartComponent {
private platformId = inject(PLATFORM_ID);
isBrowser = isPlatformBrowser(this.platformId);
constructor() {
// afterNextRender — runs only in the browser after hydration
afterNextRender(() => {
// Safe to use window, document, localStorage
this.initializeChart();
});
}
private initializeChart() {
// Browser-only code
const container = document.querySelector('#chartContainer');
// Initialize chart library...
}
}
// Alternative: use afterRender for recurring effects
import { afterRender } from '@angular/core';
@Component({ /* ... */ })
export class ScrollTracker {
constructor() {
afterRender(() => {
// Runs after every render in the browser
console.log('Scroll position:', window.scrollY);
});
}
}Fix 3: Route-Level Rendering Strategy
// src/app/app.routes.server.ts — control SSR per route
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
// Pre-render at build time (static)
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'about', renderMode: RenderMode.Prerender },
{ path: 'pricing', renderMode: RenderMode.Prerender },
// Server-side render on each request
{ path: 'dashboard', renderMode: RenderMode.Server },
{ path: 'profile', renderMode: RenderMode.Server },
// Client-only rendering (no SSR)
{ path: 'admin/**', renderMode: RenderMode.Client },
// Pre-render with dynamic params
{
path: 'blog/:slug',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
const posts = await fetchAllPosts();
return posts.map(post => ({ slug: post.slug }));
},
},
// Catch-all — server render everything else
{ path: '**', renderMode: RenderMode.Server },
];Fix 4: Data Fetching with Transfer State
// Angular 17+ automatically transfers HttpClient responses
// Just use provideHttpClient(withFetch()) — transfer state is built-in
@Component({
selector: 'app-posts',
template: `
@if (posts(); as postList) {
@for (post of postList; track post.id) {
<article>
<h2>{{ post.title }}</h2>
<p>{{ post.excerpt }}</p>
</article>
}
} @else {
<p>Loading...</p>
}
`,
})
export class PostsComponent {
private http = inject(HttpClient);
// Using signals (Angular 17+)
posts = toSignal(
this.http.get<Post[]>('/api/posts'),
{ initialValue: null }
);
}
// Manual transfer state (for non-HTTP data)
import { TransferState, makeStateKey } from '@angular/core';
const USERS_KEY = makeStateKey<User[]>('users');
@Component({ /* ... */ })
export class UsersComponent implements OnInit {
private transferState = inject(TransferState);
private platformId = inject(PLATFORM_ID);
users: User[] = [];
async ngOnInit() {
// Check if data was transferred from server
if (this.transferState.hasKey(USERS_KEY)) {
this.users = this.transferState.get(USERS_KEY, []);
this.transferState.remove(USERS_KEY);
return;
}
// Fetch on server, store for client
const users = await fetchUsers();
this.users = users;
if (isPlatformServer(this.platformId)) {
this.transferState.set(USERS_KEY, users);
}
}
}Fix 5: Handling Third-Party Libraries
// Strategy 1: Lazy import in afterNextRender
@Component({
selector: 'app-map',
template: '<div id="map" style="height: 400px;"></div>',
})
export class MapComponent {
constructor() {
afterNextRender(async () => {
const L = await import('leaflet');
const map = L.map('map').setView([51.505, -0.09], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
});
}
}
// Strategy 2: Client-only component via route config
// In app.routes.server.ts:
{ path: 'map', renderMode: RenderMode.Client }
// Strategy 3: Structural directive
@Component({
selector: 'app-editor',
template: `
<div *ngIf="isBrowser">
<ng-container *ngComponentOutlet="editorComponent" />
</div>
<div *ngIf="!isBrowser">
<p>Editor loading...</p>
</div>
`,
})
export class EditorWrapper {
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
editorComponent: any;
constructor() {
afterNextRender(async () => {
const { RichTextEditor } = await import('./rich-text-editor.component');
this.editorComponent = RichTextEditor;
});
}
}Fix 6: Deployment
# Build with SSR
ng build
# Output structure:
# dist/my-app/
# ├── browser/ # Client-side assets
# └── server/ # Server bundle
# Run the production server
node dist/my-app/server/server.mjs// server.ts — Express server (auto-generated)
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr/node';
import express from 'express';
const server = express();
const commonEngine = new CommonEngine();
server.get('**', (req, res, next) => {
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: req.url,
providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }],
})
.then(html => res.send(html))
.catch(err => next(err));
});
server.listen(4000);// Deploy to different platforms:
// Vercel: ng add @angular/ssr --server-routing
// Netlify: use @netlify/angular
// Firebase: ng add @angular/fireStill Not Working?
“window is not defined” — direct window access crashes the server. Use isPlatformBrowser() to guard browser-only code, or use afterNextRender() which only runs in the browser.
Hydration errors — the server and client render different HTML. Common causes: *ngIf based on isPlatformBrowser (server renders false, client renders true), async data loading without transfer state, or time-dependent content (dates, randomness).
SSR returns blank HTML — the server build might have errors. Check dist/my-app/server/ exists. Also verify that provideServerRendering() is in the server config and routes are configured in app.routes.server.ts.
API calls happen twice — without transfer state, the server fetches data and renders HTML, then the client fetches the same data again. Use provideHttpClient(withFetch()) in Angular 17+ — it automatically transfers HTTP responses from server to client.
Tutorial says @nguniversal/express-engine but my project uses @angular/ssr — the tutorial predates Angular 17. The @nguniversal/* packages were superseded by @angular/ssr in November 2023. Run ng add @angular/ssr on an Angular 17+ project to set up the modern equivalent. The mental model is the same (Node.js Express server, render Angular to HTML, hydrate on the client) but the imports, the server entry file, and the route-level rendering API are all different.
Click events do nothing for the first second after page load — without event replay, events that fire before hydration completes are lost. Add withEventReplay() to provideClientHydration (Angular 18+) and Angular will queue and replay those events once hydration finishes. This is the most common production complaint that does not surface in development because dev builds hydrate fast enough to hide it.
Hydration mismatch warnings only in production — production builds enable Angular’s strict hydration checks and tree-shake differently. A Math.random() used as a tracking ID, an inject(LOCALE_ID) that resolves differently on server vs client, or an @if branching on a service that has a different value on each side all produce hydration mismatches. The fix is to make the server and client render the same DOM: hoist random IDs to a service that uses transfer state, set the locale explicitly during bootstrap, and avoid branching on browser-only state during the first render.
For related Angular issues, see Fix: Angular Signals Not Updating, Fix: Angular Standalone Component Error, Fix: Angular Change Detection Not Working, and Fix: Angular Lazy Load Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Analog Not Working — Routes Not Loading, API Endpoints Failing, or Vite Build Errors
How to fix Analog (Angular meta-framework) issues — file-based routing, API routes with Nitro, content collections, server-side rendering, markdown pages, and deployment.
Fix: Astro Actions Not Working — Form Submission Failing, Validation Errors Missing, or Return Type Wrong
How to fix Astro Actions issues — action definition, Zod validation, form handling, progressive enhancement, error handling, file uploads, and calling actions from client scripts.
Fix: SolidStart Not Working — Routes Not Rendering, Server Functions Failing, or Hydration Errors
How to fix SolidStart issues — file-based routing, server functions, createAsync data loading, middleware, sessions, and deployment configuration.
Fix: TanStack Start Not Working — Server Functions Failing, Routes Not Loading, or SSR Errors
How to fix TanStack Start issues — project setup, file-based routing, server functions with createServerFn, data loading, middleware, SSR hydration, and deployment configuration.