Angular Universal — Server-Side Rendering for Angular Applications
In this tutorial, you will learn about Angular Universal. We cover key concepts, practical examples, and best practices to help you master this topic.
Angular Universal enables SSR for Angular applications, rendering pages on the Node.js server for faster initial load, SEO improvement, and social media preview support.
What You'll Learn
By the end of this tutorial, you will understand how to add Angular Universal to an existing Angular project, how server-side rendering works in Angular, how to handle browser-only APIs, how to manage state transfer from server to client, and how to deploy an Angular Universal application.
Why It Matters
Angular is powerful for complex SPAs, but client-only Angular apps struggle with SEO and slow initial loads. Angular Universal solves these problems by rendering the application on the server first, sending fully-formed HTML to the browser, and then transferring to client-side rendering.
Real-World Use
A data-heavy dashboard built with Angular had 6-second initial load times and zero SEO. After adding Angular Universal, First Contentful Paint dropped to 1.2 seconds, and public-facing pages became indexable by Google. The Angular Universal setup took 2 days with minimal code changes.
Angular Universal SSR Flow
┌──────────────────────────────────────────────────────────┐
│ Angular Universal SSR Process │
├──────────────────────────────────────────────────────────┤
│ │
│ 1. Request comes to Node.js server │
│ │
│ 2. Angular Universal platform-server renders app │
│ a. Bootstraps the AppModule on server │
│ b. Runs through change detection once │
│ c. Resolves all async tasks (HTTP, guards) │
│ d. Serializes the component tree to HTML │
│ │
│ 3. Full HTML returned to browser │
│ <app-root _nghost-xxx>...rendered content...</app-root>│
│ │
│ 4. Browser displays HTML immediately (if CSS loaded) │
│ │
│ 5. Client Angular app loads and bootstraps │
│ Transfers state from server to client │
│ Attaches event handlers │
│ Becomes interactive │
│ │
└──────────────────────────────────────────────────────────┘
Think of Angular Universal like a play with a dress rehearsal. The dress rehearsal (server render) runs through the entire play, checks all the props (data), and prepares everything. The audience (users) arrives to find the stage fully set and the actors in position. Then the actual performance (client hydration) begins with full interactivity.
Adding Angular Universal to an Existing Project
# Add Angular Universal to existing project
ng add @nguniversal/express-engine
# This command creates:
# server.ts — Express server
# src/main.server.ts — Server entry point
# src/app/app.server.module.ts — Server-side module
# tsconfig.server.json — Server TypeScript config
# webpack.server.config.js — Server bundler config
# Build and run
npm run build:ssr
npm run serve:ssr
// server.ts — Express server for Angular Universal
import 'zone.js/dist/zone-node';
import { ngExpressEngine } from '@nguniversal/express-engine';
import express from 'express';
import { AppServerModule } from './src/main.server';
import { APP_BASE_HREF } from '@angular/common';
import { existsSync } from 'fs';
const app = express();
// Setup Angular Universal engine
app.engine('html', ngExpressEngine({
bootstrap: AppServerModule,
}));
app.set('view engine', 'html');
app.set('views', 'dist/browser');
// Serve static files
app.get('*.*', express.static('dist/browser', {
maxAge: '1y'
}));
// All regular routes — Angular Universal handles them
app.get('*', (req, res) => {
res.render('index', {
req,
res,
providers: [
{ provide: APP_BASE_HREF, useValue: req.baseUrl },
{ provide: 'REQUEST', useValue: req },
{ provide: 'RESPONSE', useValue: res }
]
});
});
app.listen(4000, () => {
console.log('Angular Universal running on http://localhost:4000');
});
Handling Browser-Only APIs
// Component with browser-only code
import { Component, Inject, PLATFORM_ID, OnInit } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
@Component({
selector: 'app-user-profile',
template: `
<div>
<h1>{{ userName }}</h1>
<p>Last visit: {{ lastVisit }}</p>
<button *ngIf="!isServer" (click)="refresh()">Refresh</button>
</div>
`
})
export class UserProfileComponent implements OnInit {
userName = 'Guest';
lastVisit = 'Unknown';
isServer = isPlatformServer(this.platformId);
constructor(
@Inject(PLATFORM_ID) private platformId: Object,
@Inject('REQUEST') private request: any
) {
// Access request headers on server
if (isPlatformServer(this.platformId)) {
this.userName = request?.cookies?.userName || 'Guest';
}
}
ngOnInit() {
// This runs on both server and client
// For client-only operations:
if (isPlatformBrowser(this.platformId)) {
this.lastVisit = localStorage.getItem('lastVisit') || 'First visit';
}
}
refresh() {
console.log('Refreshing data...');
}
}
State Transfer
// Service with state transfer
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TransferState, makeStateKey } from '@angular/platform-browser';
import { tap } from 'rxjs/operators';
const PRODUCTS_KEY = makeStateKey<Product[]>('products');
@Injectable({ providedIn: 'root' })
export class ProductService {
constructor(
private http: HttpClient,
private transferState: TransferState
) {}
getProducts() {
// Check if data is already transferred from server
const stored = this.transferState.get(PRODUCTS_KEY, null);
if (stored) {
// Client: use data transferred from server
this.transferState.remove(PRODUCTS_KEY);
return stored;
}
// Server: fetch data and transfer to client
return this.http.get<Product[]>('/api/products')
.pipe(
tap(data => {
this.transferState.set(PRODUCTS_KEY, data);
})
);
}
}
// Component using the service
@Component({ ... })
export class ProductListComponent implements OnInit {
products: Product[] = [];
constructor(private productService: ProductService) {}
ngOnInit() {
this.productService.getProducts();
}
}
Common Mistakes
- Not handling platform-specific code. Code that uses localStorage, document, or window crashes during SSR. Always check isPlatformBrowser() before using browser APIs.
- Forgetting to use TransferState. Without TransferState, the client re-fetches data that the server already fetched. This defeats the purpose of SSR. Always use TransferState for data fetched on the server.
- Using setTimeout or setInterval in constructor. These create side effects during SSR. Move timers to ngOnInit with platform checks.
- Third-party libraries without Universal support. Some libraries use browser APIs directly. Mock these libraries on the server or use isPlatformBrowser guards.
- Not handling 404s properly. Angular Universal should return 404 status for unknown routes. Use the RESPONSE injection token to set status codes.
Practice Questions
- How does Angular Universal render components on the server?
- What is the purpose of TransferState in Angular Universal?
- How do you handle browser-only APIs in Universal?
- What files does ng add @nguniversal/Express-engine create?
- How do you set HTTP status codes in Angular Universal?
Challenge: Add Angular Universal to an existing Angular application: set up the Express server with ngExpressEngine, add TransferState for API data transfer, handle browser-only APIs with isPlatformBrowser checks, create server-side 404 handling, and deploy the SSR application.
FAQ
Mini Project
Add Angular Universal to a product catalog Angular app: configure Express server with SSR, add TransferState for product data transfer, handle localStorage for user preferences with platform checks, create a server-side 404 page with proper HTTP status code, deploy with PM2, and verify Lighthouse score improvement.
What's Next
You understand Angular Universal. Now learn about Streaming SSR to stream HTML to the browser for faster Time to First Byte.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro