Module Overview
Estimated Time: 3-4 hours | Difficulty: Advanced | Prerequisites: Module 12
- SSR benefits and use cases
- Setting up Angular Universal
- Hydration and client takeover
- SEO optimization
- Handling browser APIs on server
- Deployment strategies
SSR Benefits
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Client-Side vs Server-Side Rendering │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Client-Side Rendering (CSR) Server-Side Rendering (SSR) │
│ ─────────────────────────── ───────────────────────── │
│ │
│ Browser ─────────────────▶ Browser ─────────────────▶ │
│ 1. Request HTML (empty) 1. Request HTML │
│ 2. Download JS bundles 2. Server renders HTML │
│ 3. Execute JavaScript 3. Send complete HTML │
│ 4. Render application 4. Display content (fast!) │
│ 5. Interactive 5. Download JS in background │
│ └──── Blank screen ────┘ 6. Hydration │
│ 7. Interactive │
│ Time to First Paint: SLOW Time to First Paint: FAST │
│ │
│ Benefits of SSR: │
│ ✓ Faster First Contentful Paint (FCP) │
│ ✓ Better SEO (crawlers see content) │
│ ✓ Social media link previews work │
│ ✓ Better Core Web Vitals │
│ ✓ Works without JavaScript │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Setting Up SSR
New Project with SSR
Copy
# Create new project with SSR
ng new my-app --ssr
# Or add to existing project
ng add @angular/ssr
Project Structure
Copy
my-app/
├── src/
│ ├── app/
│ │ └── app.config.ts # Client config
│ ├── main.ts # Client entry point
│ └── main.server.ts # Server entry point
├── server.ts # Express server
└── angular.json # Build configurations
Configuration Files
Copy
// app.config.ts (Client)
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser';
import { provideHttpClient, withFetch } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideClientHydration(), // Enable hydration
provideHttpClient(withFetch())
]
};
Copy
// app.config.server.ts (Server)
import { ApplicationConfig, mergeApplicationConfig } 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);
Copy
// main.server.ts
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;
Express Server
Copy
// server.ts
import 'zone.js/node';
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr';
import express from 'express';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import bootstrap from './src/main.server';
export function app(): express.Express {
const server = express();
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const indexHtml = join(serverDistFolder, 'index.server.html');
const commonEngine = new CommonEngine();
server.set('view engine', 'html');
server.set('views', browserDistFolder);
// Serve static files
server.get('*.*', express.static(browserDistFolder, {
maxAge: '1y',
index: false,
}));
// Handle all other routes with Angular
server.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));
});
return server;
}
function run(): void {
const port = process.env['PORT'] || 4000;
const server = app();
server.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
});
}
run();
Hydration
Hydration connects the server-rendered DOM to Angular:Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Hydration Process │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Server Render │
│ ┌────────────────────────────────────────────┐ │
│ │ <app-root> │ │
│ │ <div class="content"> │ │
│ │ <h1>Hello World</h1> ← Real HTML │ │
│ │ <button>Click Me</button> │ │
│ │ </div> │ │
│ │ </app-root> │ │
│ │ <script>window.__ngState__ = {...}</script>│ ← Transfer state │
│ └────────────────────────────────────────────┘ │
│ │
│ 2. Browser Receives HTML (viewable immediately) │
│ │
│ 3. JavaScript Downloads & Executes │
│ │
│ 4. Hydration │
│ • Angular attaches to existing DOM (no re-render!) │
│ • Event listeners added │
│ • Components become interactive │
│ • State transferred from server │
│ │
│ Benefits: │
│ ✓ No flickering (DOM reuse) │
│ ✓ Faster Time to Interactive │
│ ✓ State preserved from server │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Hydration Configuration
Copy
// app.config.ts
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(
withEventReplay() // Replay events that occur before hydration
)
]
};
Handling Browser APIs
Browser APIs (window, document, localStorage) don’t exist on the server:Copy
import { Component, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
@Component({...})
export class HeaderComponent {
private platformId = inject(PLATFORM_ID);
ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
// Only runs in browser
window.addEventListener('scroll', this.onScroll);
const theme = localStorage.getItem('theme');
}
if (isPlatformServer(this.platformId)) {
// Only runs on server
console.log('Rendering on server');
}
}
}
afterNextRender and afterRender
Copy
import { Component, afterNextRender, afterRender } from '@angular/core';
@Component({
selector: 'app-chart',
template: `<div #chartContainer></div>`
})
export class ChartComponent {
@ViewChild('chartContainer') container!: ElementRef;
constructor() {
// Runs ONCE after first render in browser
afterNextRender(() => {
// Safe to use browser APIs here
this.initChart();
});
// Runs EVERY render in browser
afterRender(() => {
// Update chart on each render
this.updateChart();
});
}
private initChart() {
// Chart.js, D3, etc.
new Chart(this.container.nativeElement, {...});
}
}
Transfer State
Share data between server and client to avoid duplicate requests:Copy
// data.service.ts
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 { of, tap } from 'rxjs';
const USERS_KEY = makeStateKey<User[]>('users');
@Injectable({ providedIn: 'root' })
export class DataService {
private http = inject(HttpClient);
private transferState = inject(TransferState);
private platformId = inject(PLATFORM_ID);
getUsers(): Observable<User[]> {
// Check if data was transferred from server
if (this.transferState.hasKey(USERS_KEY)) {
const users = this.transferState.get(USERS_KEY, []);
this.transferState.remove(USERS_KEY); // Clean up
return of(users);
}
// Fetch from API
return this.http.get<User[]>('/api/users').pipe(
tap(users => {
// Save to transfer state on server
if (isPlatformServer(this.platformId)) {
this.transferState.set(USERS_KEY, users);
}
})
);
}
}
Automatic HttpClient Transfer
Angular can automatically handle HTTP transfer state:Copy
// app.config.ts
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withFetch()),
provideClientHydration(
withHttpTransferCacheOptions({
includePostRequests: true // Cache POST requests too
})
)
]
};
SEO Optimization
Meta Tags and Title
Copy
// seo.service.ts
import { Injectable, inject } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
@Injectable({ providedIn: 'root' })
export class SeoService {
private meta = inject(Meta);
private title = inject(Title);
updatePageMeta(config: {
title: string;
description: string;
image?: string;
url?: string;
}) {
// Title
this.title.setTitle(config.title);
// Standard meta tags
this.meta.updateTag({ name: 'description', content: config.description });
// Open Graph (Facebook, LinkedIn)
this.meta.updateTag({ property: 'og:title', content: config.title });
this.meta.updateTag({ property: 'og:description', content: config.description });
if (config.image) {
this.meta.updateTag({ property: 'og:image', content: config.image });
}
if (config.url) {
this.meta.updateTag({ property: 'og:url', content: config.url });
}
// Twitter Card
this.meta.updateTag({ name: 'twitter:card', content: 'summary_large_image' });
this.meta.updateTag({ name: 'twitter:title', content: config.title });
this.meta.updateTag({ name: 'twitter:description', content: config.description });
if (config.image) {
this.meta.updateTag({ name: 'twitter:image', content: config.image });
}
}
}
Copy
// product-detail.component.ts
@Component({...})
export class ProductDetailComponent {
private seoService = inject(SeoService);
private route = inject(ActivatedRoute);
product = toSignal(
this.route.data.pipe(map(data => data['product']))
);
constructor() {
effect(() => {
const product = this.product();
if (product) {
this.seoService.updatePageMeta({
title: `${product.name} | My Store`,
description: product.description.slice(0, 160),
image: product.image,
url: `https://mystore.com/products/${product.id}`
});
}
});
}
}
Structured Data (JSON-LD)
Copy
// structured-data.service.ts
import { Injectable, inject, Renderer2, DOCUMENT } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class StructuredDataService {
private document = inject(DOCUMENT);
setProductSchema(product: Product) {
const schema = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
image: product.image,
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'USD',
availability: product.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock'
}
};
this.setJsonLd(schema);
}
setArticleSchema(article: Article) {
const schema = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.title,
author: {
'@type': 'Person',
name: article.author
},
datePublished: article.publishDate,
image: article.image
};
this.setJsonLd(schema);
}
private setJsonLd(schema: object) {
// Remove existing
const existing = this.document.querySelector('script[type="application/ld+json"]');
if (existing) {
existing.remove();
}
// Add new
const script = this.document.createElement('script');
script.type = 'application/ld+json';
script.text = JSON.stringify(schema);
this.document.head.appendChild(script);
}
}
Prerendering (SSG)
Generate static HTML at build time:Copy
// angular.json
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"prerender": {
"routesFile": "routes.txt",
// Or discover routes automatically
"discoverRoutes": true
}
}
}
}
}
}
}
// routes.txt
/
/about
/products/1
/products/2
/blog/post-1
/blog/post-2
Copy
# Build with prerendering
ng build
# Output
dist/
├── browser/
│ ├── index.html # Prerendered home page
│ ├── about/index.html # Prerendered about page
│ ├── products/
│ │ ├── 1/index.html
│ │ └── 2/index.html
│ └── ...
└── server/
└── ...
Deployment
Node.js Server
Copy
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY dist ./dist
EXPOSE 4000
CMD ["node", "dist/server/server.mjs"]
Copy
# docker-compose.yml
version: '3.8'
services:
angular-ssr:
build: .
ports:
- "4000:4000"
environment:
- NODE_ENV=production
Serverless (AWS Lambda, Cloud Functions)
Copy
// serverless handler
import { Handler } from 'aws-lambda';
import { app } from './server';
const server = app();
export const handler: Handler = async (event, context) => {
// Use serverless-http or similar adapter
return await serverlessExpress({ app: server })(event, context);
};
Static Hosting (with prerendering)
Copy
# Build static files
ng build
# Deploy browser folder to:
# - Netlify
# - Vercel
# - AWS S3 + CloudFront
# - Firebase Hosting
Practice Exercise
Exercise: Add SSR to a Blog
Add SSR to a blog application:
- Configure Angular Universal
- Add SEO meta tags for posts
- Implement transfer state for API data
- Add structured data for articles
- Handle client-only components
Solution
Solution
Copy
// blog-post.component.ts
@Component({
selector: 'app-blog-post',
standalone: true,
template: `
@if (post(); as post) {
<article>
<h1>{{ post.title }}</h1>
<div class="meta">
<span>{{ post.author }}</span>
<time>{{ post.date | date }}</time>
</div>
<div [innerHTML]="post.content"></div>
<!-- Client-only: Comments with real-time updates -->
@defer (on idle) {
<app-comments [postId]="post.id" />
} @placeholder {
<div class="comments-placeholder">Loading comments...</div>
}
</article>
}
`
})
export class BlogPostComponent {
private route = inject(ActivatedRoute);
private seoService = inject(SeoService);
private structuredData = inject(StructuredDataService);
private blogService = inject(BlogService);
post = toSignal(
this.route.params.pipe(
switchMap(params => this.blogService.getPost(params['slug']))
)
);
constructor() {
effect(() => {
const post = this.post();
if (post) {
// SEO Meta Tags
this.seoService.updatePageMeta({
title: `${post.title} | My Blog`,
description: post.excerpt,
image: post.featuredImage,
url: `https://myblog.com/posts/${post.slug}`
});
// Structured Data
this.structuredData.setArticleSchema({
title: post.title,
author: post.author,
publishDate: post.date,
image: post.featuredImage
});
}
});
}
}
// blog.service.ts
@Injectable({ providedIn: 'root' })
export class BlogService {
private http = inject(HttpClient);
private transferState = inject(TransferState);
private platformId = inject(PLATFORM_ID);
getPost(slug: string): Observable<BlogPost> {
const key = makeStateKey<BlogPost>(`post-${slug}`);
if (this.transferState.hasKey(key)) {
const post = this.transferState.get(key, null as any);
this.transferState.remove(key);
return of(post);
}
return this.http.get<BlogPost>(`/api/posts/${slug}`).pipe(
tap(post => {
if (isPlatformServer(this.platformId)) {
this.transferState.set(key, post);
}
})
);
}
}
// comments.component.ts (client-only)
@Component({
selector: 'app-comments',
standalone: true,
template: `
<section class="comments">
@for (comment of comments(); track comment.id) {
<div class="comment">
<strong>{{ comment.author }}</strong>
<p>{{ comment.text }}</p>
</div>
}
<form (submit)="addComment($event)">
<textarea [(ngModel)]="newComment"></textarea>
<button type="submit">Post Comment</button>
</form>
</section>
`
})
export class CommentsComponent {
postId = input.required<number>();
private commentService = inject(CommentService);
comments = signal<Comment[]>([]);
newComment = '';
constructor() {
// This only runs in browser (component is deferred)
afterNextRender(() => {
this.loadComments();
this.commentService.subscribeToUpdates(this.postId());
});
}
loadComments() {
this.commentService.getComments(this.postId())
.subscribe(comments => this.comments.set(comments));
}
addComment(event: Event) {
event.preventDefault();
this.commentService.addComment(this.postId(), this.newComment)
.subscribe(() => {
this.newComment = '';
this.loadComments();
});
}
}
Summary
1
SSR Setup
Use @angular/ssr for built-in server-side rendering
2
Hydration
Enable provideClientHydration() for seamless client takeover
3
Platform Checks
Use isPlatformBrowser/isPlatformServer for platform-specific code
4
Transfer State
Share data between server and client to avoid duplicate requests
5
SEO
Use Meta, Title services and structured data for search engines
Next Steps
Next: Security Best Practices
Learn security patterns to protect your Angular application