Angular 75 🅰️ Angular Universal — Server-Side Rendering
Every Angular application you have written so far has been a client-side application. The browser downloads a minimal HTML shell, loads the JavaScript bundle, executes Angular, renders the component tree, fetches data, and finally paints content on the screen. The user stares at a blank page until that entire chain completes. Server-side rendering changes the order of operations. The server renders the application to a complete HTML string, sends it to the browser, and the browser displays content immediately — before the JavaScript bundle even arrives.
Angular Universal is the name for Angular’s server-side rendering solution. In older Angular versions, it was a separate package (@nguniversal/express-engine) that required additional setup. In modern Angular, SSR is built into the framework and the CLI. Creating a new project with ng new --ssr or adding it to an existing project with ng add @angular/ssr configures everything: the server entry point, the Express server, the build targets, and the hydration providers.
Key point: SSR is not a replacement for client-side rendering. It is a first-render enhancement. The server produces the initial HTML. Once the browser loads the JavaScript and Angular bootstraps, the application hydrates — it reuses the server-rendered DOM instead of recreating it — and from that point forward, the application runs as a normal SPA. Every route change, every interaction, every API call after the initial load happens on the client, exactly as it did before.
Why SSR exists
The default Angular rendering model — client-side rendering — has three weaknesses that SSR addresses directly.
The blank page problem. When a browser requests a CSR Angular page, it receives an HTML document with almost no content — typically just <app-root></app-root> and a script tag. The browser must download the JavaScript bundle, parse it, execute Angular, render the component tree, and fetch initial data before anything appears on screen. On a slow connection or a low-end device, this can take several seconds of white screen. SSR sends fully rendered HTML immediately. The browser can paint content before the JavaScript finishes downloading, which directly improves First Contentful Paint and Largest Contentful Paint.
The SEO problem. Search engine crawlers have limited ability to execute JavaScript. Google’s crawler can execute JavaScript, but it may not wait for asynchronous data fetching or full hydration. Other crawlers — social media preview bots, some search engines, archival services — often do not execute JavaScript at all. A CSR Angular page may appear empty to these crawlers. An SSR page contains the full content in the initial HTML response, so crawlers see everything without executing a line of JavaScript.
The accessibility problem. Users on older devices, slow networks, or with JavaScript disabled cannot use a CSR application at all. SSR delivers the content regardless of whether JavaScript runs. The application may not be interactive without JavaScript, but the information is visible. For content-focused sites — documentation, blogs, marketing pages, e-commerce product listings — this is a meaningful improvement.
The trade-off. SSR adds a server. That server has CPU and memory costs. Rendering Angular on the server is not free. Every request that triggers server rendering consumes resources. For high-traffic applications, this requires caching, careful monitoring, and infrastructure planning. Angular’s rendering strategy system lets you apply SSR per route, so you can pay the server cost only where it benefits SEO or initial load, and use client rendering for everything else.
a. How SSR Works: The Request Lifecycle
Understanding SSR requires following a single page request through the system.
When a browser requests a URL, the request reaches the Node.js Express server created by the Angular CLI schematic. That server passes the request to Angular’s rendering engine. The engine bootstraps the Angular application in a server-side context — no browser, no DOM, just Node.js. It renders the component tree to an HTML string using Angular’s platform-server APIs (renderApplication or renderModule).
The server then sends that HTML string back to the browser as the response. This HTML contains everything the application would render on the client: component markup, bound values, conditional content, list items, and any data that was fetched synchronously during server rendering. The browser parses this HTML and paints it immediately. The user sees content.
Meanwhile, the browser continues loading the JavaScript bundle. When Angular bootstraps on the client, it does not re-render the DOM from scratch. It performs hydration: it walks the existing server-rendered DOM, attaches event listeners, restores application state, and makes the page interactive. Hydration is enabled by default when you use SSR in modern Angular.
The critical rule for server-side code is this: browser globals do not exist. window, document, localStorage, sessionStorage, navigator, HTMLElement, and every other browser API are undefined on the server. Code that references them directly will throw during server rendering. Angular provides isPlatformBrowser and isPlatformServer to guard platform-specific code, and afterNextRender to defer browser-only work until the client takes over.
b. Setting Up SSR: The Modern Approach
Creating a new SSR-enabled project is a single command:
ng new my-ssr-app --ssr
The CLI asks whether you want SSR with Express and creates the full setup automatically. For an existing project, the command is:
ng add @angular/ssr
This schematic modifies angular.json to add server build targets, creates main.server.ts as the server entry point, creates app.config.server.ts for server-specific providers, and creates server.ts for the Express server configuration.
The generated server.ts uses CommonEngine to render Angular applications. The CommonEngine.render() method accepts a bootstrap function (which returns the application reference), a documentFilePath pointing to the built index.html, and the request URL. It returns a promise that resolves to the rendered HTML string.
// server.ts (generated by the schematic)
import { CommonEngine } from '@angular/ssr';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import bootstrap from './src/main.server';
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const indexHtml = join(serverDistFolder, 'index.server.html');
const app = express();
const commonEngine = new CommonEngine();
app.get('*', (req, res, next) => {
const { protocol, originalUrl, baseUrl, headers } = req;
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: `${protocol}://${headers.host}${originalUrl}`,
publicPath: browserDistFolder,
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
})
.then((html) => res.send(html))
.catch((err) => next(err));
});
In Angular v17 and later, server.ts is no longer used by ng serve during development. The dev server uses main.server.ts directly to perform server-side rendering. The server.ts file is for production deployment.
c. Hydration and TransferState: Making SSR Fast and Correct
Hydration is the process that restores the server-rendered application on the client. Without hydration, Angular would destroy the server-rendered DOM and rebuild it from scratch, causing a visible flicker and wasting the work the server already did. Hydration reuses the existing DOM, attaches event listeners, and restores application state.
Hydration is enabled by default when you use SSR. To enable it explicitly or to add optional features, you provide provideClientHydration() in the application configuration:
// app.config.ts
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(withEventReplay()),
// other providers
],
};
withEventReplay() captures user events (clicks, key presses) that occur before hydration completes and replays them once the application becomes interactive. Without event replay, clicks made during the hydration window are lost.
TransferState solves a different problem: duplicate HTTP requests. During server rendering, Angular fetches data to populate the page. When the client boots, it would normally fetch the same data again. TransferState is a key-value store that serializes server-fetched data into the HTML and makes it available to the client, so the client can skip redundant requests.
In modern Angular, HttpClient caches HEAD and GET requests during server rendering and transfers them automatically when you use provideClientHydration(). The cache is serialized into the HTML and reused during hydration. You can customize this behavior with withHttpTransferCacheOptions():
provideClientHydration(
withHttpTransferCacheOptions({
includePostRequests: false, // default: only GET and HEAD are cached
})
)
The HttpClient transfer cache stops being used once the application becomes stable in the browser. After that, normal HTTP behavior resumes.
Complete Example Session
This session creates an SSR-enabled application, guards browser-only code, configures TransferState for a data service, and sets up per-route rendering modes.
// ============================================
// PART 1: CREATE THE SSR APPLICATION
// ============================================
// Terminal:
// ng new my-ssr-app --ssr
// or for existing:
// ng add @angular/ssr
// ============================================
// PART 2: THE SERVER ENTRY POINT
// ============================================
// main.server.ts (generated)
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server';
const bootstrap = () => bootstrapApplication(AppComponent, config);
export default bootstrap;
// ============================================
// PART 3: THE SERVER CONFIG
// ============================================
// app.config.server.ts (generated)
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
// ============================================
// PART 4: GUARDING BROWSER-ONLY CODE
// ============================================
import { Component, Inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Component({
selector: 'app-platform-aware',
template: `
<p>Platform: {{ platform }}</p>
@if (isBrowser) {
<p>Window width: {{ windowWidth }}</p>
}
`,
})
export class PlatformAwareComponent {
platform: string;
isBrowser: boolean;
windowWidth?: number;
constructor(@Inject(PLATFORM_ID) platformId: object) {
this.isBrowser = isPlatformBrowser(platformId);
this.platform = this.isBrowser ? 'Browser' : 'Server';
if (this.isBrowser) {
this.windowWidth = window.innerWidth;
}
}
}
// ============================================
// PART 5: USING afterNextRender FOR BROWSER CODE
// ============================================
import { Component, afterNextRender } from '@angular/core';
@Component({
selector: 'app-after-render',
template: `<p>Content</p>`,
})
export class AfterRenderComponent {
constructor() {
afterNextRender(() => {
// Runs only in the browser, after the next render
console.log('Window height:', window.innerHeight);
});
}
}
// ============================================
// PART 6: TRANSFERSTATE FOR A DATA SERVICE
// ============================================
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TransferState, makeStateKey } from '@angular/platform-browser';
import { isPlatformServer } from '@angular/common';
import { Observable, of, tap } from 'rxjs';
const PRODUCTS_KEY = makeStateKey<Product[]>('products');
@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
private transferState = inject(TransferState);
private platformId = inject(PLATFORM_ID);
getProducts(): Observable<Product[]> {
if (this.transferState.hasKey(PRODUCTS_KEY)) {
const products = this.transferState.get(PRODUCTS_KEY, []);
this.transferState.remove(PRODUCTS_KEY);
return of(products);
}
return this.http.get<Product[]>('/api/products').pipe(
tap(products => {
if (isPlatformServer(this.platformId)) {
this.transferState.set(PRODUCTS_KEY, products);
}
})
);
}
}
// ============================================
// PART 7: THE COMPONENT USING THE SERVICE
// ============================================
@Component({
selector: 'app-product-list',
template: `
<ul>
@for (product of products$ | async; track product.id) {
<li>{{ product.name }} — {{ product.price }}</li>
}
</ul>
`,
})
export class ProductListComponent {
private productService = inject(ProductService);
products$ = this.productService.getProducts();
}
// ============================================
// PART 8: PER-ROUTE RENDERING MODES
// ============================================
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: '',
renderMode: RenderMode.Prerender,
},
{
path: 'products',
renderMode: RenderMode.Server,
},
{
path: 'admin/**',
renderMode: RenderMode.Client,
},
{
path: '**',
renderMode: RenderMode.Server,
},
];
// ============================================
// PART 9: CONFIGURING THE APP SHELL
// ============================================
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { provideServerRendering } from '@angular/ssr';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(withEventReplay()),
provideServerRendering(),
],
};
// ============================================
// PART 10: THE STATIC OUTPUT MODE
// ============================================
// angular.json — opt out of server rendering entirely
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"outputMode": "static"
}
}
}
}
}
}
// With outputMode: "static", Angular generates pre-rendered HTML
// for each route at build time. No Node.js server is required.
// Useful for deploying to static hosting providers.
The ten parts cover creating the SSR application, the server entry point, the server config, guarding browser-only code, afterNextRender, TransferState for a data service, the component using the service, per-route rendering modes, the app shell, and the static output mode.
Quick Reference
The SSR Commands
| Command | Purpose |
|---|---|
ng new --ssr | Create a new SSR project |
ng add @angular/ssr | Add SSR to an existing project |
ng build | Build client and server bundles |
ng serve | Run the dev server with SSR |
npm run serve:ssr | Run the production server |
The SSR Files
| File | Purpose |
|---|---|
server.ts | Express server configuration (production) |
main.server.ts | Server entry point |
app.config.server.ts | Server-specific providers |
app.routes.server.ts | Per-route rendering modes |
The Rendering Modes
| Mode | When HTML is Generated | Use Case |
|---|---|---|
Prerender | Build time | Static content, blogs |
Server | Per request | Dynamic content, e-commerce |
Client | Browser | Auth-gated dashboards |
The Platform Guards
| Guard | Purpose |
|---|---|
isPlatformBrowser(platformId) | Run code only in browser |
isPlatformServer(platformId) | Run code only on server |
afterNextRender(() => {}) | Defer browser-only code |
The TransferState Pattern
| Step | Code |
|---|---|
| Define key | const KEY = makeStateKey<T>('name') |
| Check cache | if (transferState.hasKey(KEY)) |
| Read cached | transferState.get(KEY, null) |
| Store on server | if (isPlatformServer(platformId)) transferState.set(KEY, data) |
| Remove after read | transferState.remove(KEY) |
Best Practices
✅ Do This:
// Guard browser globals with isPlatformBrowser
if (isPlatformBrowser(platformId)) { window.scrollTo(0, 0); } // ✅
// Use afterNextRender for browser-only side effects
afterNextRender(() => { /* window access */ }); // ✅
// Enable hydration with event replay
provideClientHydration(withEventReplay()) // ✅
// Use TransferState to avoid duplicate HTTP requests
transferState.set(KEY, data); // ✅
// Set RenderMode.Client for auth-gated routes
{ path: 'admin/**', renderMode: RenderMode.Client } // ✅
❌ Don’t Do This:
// Don't access window directly without a guard
window.innerWidth; // ❌ crashes on server
// Don't use localStorage without checking platform
localStorage.getItem('key'); // ❌ crashes on server
// Don't forget to remove TransferState keys after reading
transferState.get(KEY, null); // without remove // ❌ stale data
// Don't use hash routing with SSR
RouterModule.forRoot(routes, { useHash: true }) // ❌ breaks SSR
// Don't apply SSR to authenticated dashboards
{ path: 'admin', renderMode: RenderMode.Server } // ❌ no user context
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
window is not defined | Browser global accessed on server | Guard with isPlatformBrowser |
localStorage is not defined | Browser storage accessed on server | Guard or use afterNextRender |
| Duplicate HTTP requests | No TransferState configured | Use provideClientHydration() or manual TransferState |
| Hydration mismatch | Server and client render different content | Ensure deterministic rendering; avoid Date.now() |
Failed to lookup view index.html | Express views directory misconfigured | Verify views path in server.ts |
| High CPU usage on server | Expensive rendering per request | Use caching, render modes, or static output |
| Auth state missing on server | No user context in SSR | Set RenderMode.Client for auth routes |
Real-World Examples
1. Platform Guard
if (isPlatformBrowser(this.platformId)) { window.scrollTo(0, 0); }
2. afterNextRender
afterNextRender(() => { document.body.classList.add('ready'); });
3. TransferState Service
if (this.transferState.hasKey(KEY)) { return of(this.transferState.get(KEY, null)); }
4. Hydration with Event Replay
provideClientHydration(withEventReplay())
5. Per-Route Render Mode
{ path: 'blog/**', renderMode: RenderMode.Prerender }
6. Client-Only Route
{ path: 'dashboard', renderMode: RenderMode.Client }
7. Server Route
{ path: 'products', renderMode: RenderMode.Server }
8. Static Output Mode
{ "outputMode": "static" }
9. Express Server Views
const distFolder = join(process.cwd(), 'dist/my-app/browser');
server.set('views', distFolder);
10. DOCUMENT Token
private document = inject(DOCUMENT);
Visual: SSR Request Lifecycle
┌──────────────────────────────────────────────┐
│ SSR REQUEST LIFECYCLE │
│ │
│ Browser ──GET /products──> Node Server │
│ │ │
│ ▼ │
│ CommonEngine.render() │
│ ├─ Bootstrap Angular (server) │
│ ├─ Render component tree → HTML string │
│ └─ Return HTML │
│ │ │
│ ▼ │
│ Browser <── Full HTML ────────┘ │
│ │ │
│ ├─ Paint content immediately │
│ └─ Load JS bundle in background │
│ │ │
│ ▼ │
│ Hydration: reuse DOM, attach events │
│ └─ App becomes interactive SPA │
│ │
└──────────────────────────────────────────────┘
Visual: CSR vs SSR
┌──────────────────────────────────────────────┐
│ CSR (Client-Side Rendering) │
│ │
│ Request → Empty HTML → JS Download → │
│ Parse → Execute → Fetch Data → Render │
│ │
│ User sees: white screen for seconds │
│ Crawler sees: empty page │
│ │
├──────────────────────────────────────────────┤
│ SSR (Server-Side Rendering) │
│ │
│ Request → Server renders HTML → │
│ Browser paints immediately → │
│ JS loads → Hydration → Interactive │
│ │
│ User sees: content immediately │
│ Crawler sees: full HTML │
│ │
└──────────────────────────────────────────────┘
Visual: Rendering Strategy Decision
┌──────────────────────────────────────────────┐
│ CHOOSING A RENDER MODE │
│ │
│ Need SEO? │
│ ├─ YES → Content changes often? │
│ │ ├─ YES → RenderMode.Server │
│ │ └─ NO → RenderMode.Prerender │
│ └─ NO → RenderMode.Client │
│ │
│ Auth-gated? │
│ └─ YES → RenderMode.Client │
│ │
│ Hybrid: different modes per route │
│ │
└──────────────────────────────────────────────┘
Visual: TransferState Flow
┌──────────────────────────────────────────────┐
│ TRANSFERSTATE │
│ │
│ Server: │
│ HTTP GET /api/products │
│ └─ Response stored in TransferState │
│ └─ Serialized into HTML as JSON │
│ │
│ Client: │
│ └─ Reads TransferState from HTML │
│ └─ Returns cached data, no HTTP call │
│ └─ Removes key after reading │
│ │
│ Result: one fetch, no duplication │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| SSR definition | Server renders initial HTML |
| Setup command | ng add @angular/ssr |
| Hydration | Reuses server DOM, default enabled |
| TransferState | Prevents duplicate HTTP requests |
| Platform guards | isPlatformBrowser, isPlatformServer |
| Browser-only code | afterNextRender |
| Render modes | Prerender, Server, Client |
| Static output | outputMode: "static" |
| Server file | server.ts with CommonEngine |
| Request tokens | REQUEST, RESPONSE_INIT, DOCUMENT |
Key takeaways:
- SSR renders the application on the server and sends complete HTML. The browser paints content immediately, improving First Contentful Paint, Largest Contentful Paint, and Cumulative Layout Shift. Crawlers see the full page without executing JavaScript.
- Hydration makes the server-rendered page interactive. Angular reuses the existing DOM, attaches event listeners, and restores state. It is enabled by default with
provideClientHydration(). Event replay captures user interactions during the hydration window. - Browser globals do not exist on the server.
window,document,localStorage, andnavigatorare undefined. Guard platform-specific code withisPlatformBrowseror defer it withafterNextRender. - TransferState prevents duplicate HTTP requests. The server stores fetched data in a serialized cache that the client reads during hydration.
HttpClientdoes this automatically for GET and HEAD requests when hydration is enabled. - Angular supports per-route rendering modes. Use
RenderMode.Prerenderfor static content,RenderMode.Serverfor dynamic SEO-critical pages, andRenderMode.Clientfor auth-gated dashboards. This lets you apply SSR only where it helps. outputMode: "static"generates pre-rendered HTML at build time. No Node.js server is required. This is the right choice for blogs, documentation, and marketing sites deployed to static hosting.- SSR adds server cost and complexity. Every server-rendered request consumes CPU. Use caching, static output, or per-route modes to manage the cost. For authenticated dashboards and highly interactive applications, client rendering is often the better choice.
Remember: Angular Universal is not a different framework. It is the same Angular application running in a second environment. The server renders the initial HTML, the browser hydrates it into an interactive SPA, and TransferState bridges the data between them. The discipline is platform awareness: code that touches the DOM must be guarded, data fetching must be transfer-aware, and rendering modes must be chosen per route. When applied correctly, SSR gives you the SEO and initial-load benefits of a server-rendered page without giving up the interactivity of a single-page application. When applied indiscriminately, it adds server cost for no benefit. Choose your rendering strategy per route, and let the framework handle the rest.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!