Server-Side Rendering in Angular
By Syncfusion®, Inc. · Last updated
Rendering pages on a Node.js server: the SSR architecture, pre-rendered HTML, hydration and event replay, data fetching and transfer, and a Blog Portal capstone project.
Introduction to Server-Side Rendering
In the previous lesson, the browser received an empty page and created all the HTML itself. The user saw nothing until the JavaScript had downloaded and run.
Server-Side Rendering solves this problem: the user gets a page that already contains the content.
What is Server-Side Rendering (SSR)?
In Server-Side Rendering (SSR), a Node.js server runs your Angular app for each request. It creates the full HTML of the page, with the data, and sends it to the browser. The browser shows this HTML at once.
Then the browser downloads the JavaScript, and Angular makes the page interactive.
Real-World Example
A news website publishes new articles every hour. A reader opens an article from a search engine. The server renders the article with its text and sends the finished HTML, so the text appears immediately, and search engines can read it.
A few moments later, Angular starts in the browser, and the comment button and menus start to work.
RequestGET /posts/hydrationServerrenders + gets dataFull HTMLvisible at onceHydrationbuttons start to workThe user sees the content first. Then Angular makes it interactive.
Server-Side Rendering: the server creates the HTML for each request.
Benefits of SSR
- Fast first view. The user sees the content before the JavaScript has downloaded.
- Better for search engines. Search engines receive the full text in the HTML.
- Better link previews. Chat apps and social networks show the page title and text when someone shares a link.
- Works on slow devices. The server does the first rendering, not the user's phone.
- Always current. The page is rendered for each request, so it always shows the newest data.
SSR Architecture
Angular SSR Overview
Angular SSR is in the package @angular/ssr. Add it when you create a project:
ng new my-blog --ssr
Or add it to an existing project:
ng add @angular/ssr
The CLI adds these files:
| File | Job |
|---|---|
src/server.ts | The Node.js server (Express). It receives requests and calls Angular |
src/main.server.ts | Starts the app on the server |
src/app/app.config.server.ts | Providers used only on the server |
src/app/app.routes.server.ts | The render mode of each route |
In app.routes.server.ts, RenderMode.Server means "render this route on the server for each request":
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Server },
{ path: 'posts/:slug', renderMode: RenderMode.Server },
{ path: '**', renderMode: RenderMode.Server },
];
ng serve also renders the pages on the server in an SSR project. So you see SSR problems, such as code that uses window, already during development.
Server Rendering Process
For each request, the server does these steps:
- It finds the route that matches the URL.
- It creates the components of that route, the same way as in the browser.
- It waits until the components have their data, for example from
HttpClient. - It turns the rendered components into an HTML text.
- It sends this HTML to the browser.
Request-Response Lifecycle
- The user opens
https://myblog.com/posts/hydration. - The request reaches
server.ts. - Angular renders the post page on the server.
- The response, the full HTML, goes back to the browser.
- The browser shows the HTML and downloads the JavaScript.
- Angular starts in the browser and connects to the existing HTML.
The practice blocks on this page run in your browser, so they use CSR. They show the code that you write in an SSR app. The downloadable project at the end runs real SSR with a Node.js server.
SSR Rendering Fundamentals
Initial Page Render
On the server, the initial page render creates the HTML with the data. Compare the first HTML that the browser receives:
<!-- CSR: the browser receives -->
<app-root></app-root>
<!-- SSR: the browser receives -->
<app-root>
<h1>Latest posts</h1>
<article><h2>What is SSR?</h2><p>Asha Rao</p></article>
</app-root>
Pre-rendered HTML
The HTML from the server is pre-rendered: it was created before it reached the browser. The browser shows it at once, but its buttons do not work yet, because Angular has not started in the browser.
Interactive UI Initialization
Your components run two times: once on the server and once in the browser. The server has no browser, so these do not exist there:
window,documentandlocalStorage- The size of the screen
- Clicks and key presses
To check where the code runs, inject PLATFORM_ID and use isPlatformBrowser():
Where is the code running?
import { Component, PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Component({
selector: 'app-root',
template: `
<p>This component is running in the {{ isBrowser ? 'browser' : 'server' }}.</p>
<p>Saved theme: {{ theme }}</p>
`,
})
export class App {
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
theme = 'light';
constructor() {
// localStorage exists only in the browser
if (this.isBrowser) {
this.theme = this.readTheme();
}
}
readTheme() {
try {
return localStorage.getItem('theme') ?? 'light';
} catch {
return 'light';
}
}
}
On the server, this component shows "server" and does not read localStorage.
A simpler way for browser-only code is afterNextRender(). Angular runs its function only in the browser, after the component is rendered. On the server it never runs.
Browser-only code with afterNextRender()
import { Component, afterNextRender, signal } from '@angular/core';
@Component({
selector: 'app-root',
template: `
<p>Screen width: {{ width() }}</p>
<p>Page title: {{ title() }}</p>
`,
})
export class App {
width = signal('unknown');
title = signal('unknown');
constructor() {
// Runs only in the browser, after the first render
afterNextRender(() => {
this.width.set(window.innerWidth > 600 ? 'wide' : 'narrow');
this.title.set(document.title || 'no title');
});
}
}
Like inject(), call afterNextRender() in the constructor.
Hydration Process
What is Hydration?
When the JavaScript has downloaded, Angular starts in the browser. Without hydration, it would delete the HTML from the server and create it again. The screen would flash.
Without hydrationWith hydrationServer HTML shownAngular deletes itand renders againScreen flasheswork done twiceServer HTML shownAngular reuses the elementsconnects componentsButtons start to workno flash
Hydration keeps the server HTML and makes it interactive.
Hydration means that Angular reuses the HTML from the server. It walks through the existing elements and connects them to the components, instead of creating new elements.
Turn it on in app.config.ts. The CLI adds it for you with --ssr:
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [provideClientHydration(withEventReplay())],
};
Event Binding Restoration
During hydration, Angular connects every (click) and other event binding to its element. After that, the buttons work.
A user can click before hydration is finished. withEventReplay() records these early clicks and runs them again (replays them) when hydration finishes, so no click is lost.
Hydrated Angular Applications
After hydration, the app is a normal Angular app running in the browser. Navigation with routerLink happens in the browser, like CSR. Only the first page of a visit comes from the server.
The server HTML and the first browser rendering must be the same. If a template shows different content in the browser, for example a random number or the current time in seconds, hydration finds a difference. Angular then reports a hydration error in the browser console.
Data Fetching in SSR
Server-Side Data Fetching
You use HttpClient in the same way as in CSR. On the server, the request runs on the server, and Angular waits for the answer before it sends the HTML.
Loading posts with loading and error states
import { ApplicationConfig, Component, inject, signal } from '@angular/core';
import {
HttpClient, HttpErrorResponse, HttpHandlerFn, HttpRequest, HttpResponse,
provideHttpClient, withInterceptors,
} from '@angular/common/http';
import { delay, mergeMap, of, throwError, timer } from 'rxjs';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withInterceptors([practiceServer]))],
};
@Component({
selector: 'app-root',
template: `
<h4>Latest posts</h4>
@if (loading()) {
<p>Loading posts...</p>
} @else if (error()) {
<p>⚠ {{ error() }}</p>
} @else {
@for (post of posts(); track post.slug) {
<p><b>{{ post.title }}</b> by {{ post.author }}</p>
}
}
`,
})
export class App {
posts = signal<any[]>([]);
loading = signal(true);
error = signal('');
constructor() {
inject(HttpClient).get<any[]>('/api/posts').subscribe({
next: list => {
this.posts.set(list);
this.loading.set(false);
},
error: () => {
this.error.set('Could not load the posts.');
this.loading.set(false);
},
});
}
}
// ─── Practice server ──────────────────────────────────────────────
// Answers the blog API inside the browser, like in the API Integration lesson.
const POSTS = [
{ slug: 'what-is-ssr', title: 'What is SSR?', author: 'Asha Rao', body: 'The server builds the HTML of the page.' },
{ slug: 'hydration', title: 'Hydration Explained', author: 'Ravi Kumar', body: 'Angular reuses the HTML from the server.' },
];
function practiceServer(req: HttpRequest<unknown>, next: HttpHandlerFn) {
if (req.url === '/api/posts') {
return of(new HttpResponse({ status: 200, body: POSTS })).pipe(delay(300));
}
const post = POSTS.find(p => req.url === `/api/posts/${p.slug}`);
if (post) {
return of(new HttpResponse({ status: 200, body: post })).pipe(delay(300));
}
return timer(300).pipe(mergeMap(() => throwError(() => new HttpErrorResponse({ status: 404, statusText: 'Not Found' }))));
}
With SSR, the user never sees "Loading posts..." on the first page. The server waited for the posts, so the HTML already contains them.
Rendering Dynamic Content
The same request code works for a details page. The route parameter :slug comes into an @Input(). Here it is written as a setter, @Input() set slug(value), a method that runs each time the router gives a new value.
A details page with a route parameter
import { ApplicationConfig, Component, Input, inject, signal } from '@angular/core';
import {
HttpClient, HttpErrorResponse, HttpHandlerFn, HttpRequest, HttpResponse,
provideHttpClient, withInterceptors,
} from '@angular/common/http';
import { RouterLink, RouterOutlet, Routes, provideRouter, withComponentInputBinding } from '@angular/router';
import { delay, mergeMap, of, throwError, timer } from 'rxjs';
@Component({
selector: 'app-post-detail',
template: `
@if (error()) {
<p>⚠ {{ error() }}</p>
} @else {
<p><b>{{ title() }}</b></p>
}
`,
})
export class PostDetail {
private http = inject(HttpClient);
title = signal('Loading...');
error = signal('');
@Input() set slug(value: string) {
this.error.set('');
this.title.set('Loading...');
this.http.get<any>(`/api/posts/${value}`).subscribe({
next: post => this.title.set(post.title),
error: () => this.error.set('This post does not exist.'),
});
}
}
const routes: Routes = [
{ path: '', redirectTo: 'posts/hydration', pathMatch: 'full' },
{ path: 'posts/:slug', component: PostDetail },
];
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withInterceptors([practiceServer])),
],
};
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink],
template: `
<a routerLink="/posts/what-is-ssr">Post 1</a> |
<a routerLink="/posts/hydration">Post 2</a> |
<a routerLink="/posts/old-post">Old post</a>
<router-outlet />
`,
})
export class App {}
// ─── Practice server ──────────────────────────────────────────────
// Answers the blog API inside the browser, like in the API Integration lesson.
const POSTS = [
{ slug: 'what-is-ssr', title: 'What is SSR?', author: 'Asha Rao', body: 'The server builds the HTML of the page.' },
{ slug: 'hydration', title: 'Hydration Explained', author: 'Ravi Kumar', body: 'Angular reuses the HTML from the server.' },
];
function practiceServer(req: HttpRequest<unknown>, next: HttpHandlerFn) {
if (req.url === '/api/posts') {
return of(new HttpResponse({ status: 200, body: POSTS })).pipe(delay(300));
}
const post = POSTS.find(p => req.url === `/api/posts/${p.slug}`);
if (post) {
return of(new HttpResponse({ status: 200, body: post })).pipe(delay(300));
}
return timer(300).pipe(mergeMap(() => throwError(() => new HttpErrorResponse({ status: 404, statusText: 'Not Found' }))));
}
Each request is rendered again, so the page always shows the current data. A post published one minute ago appears on the next request, without a new build. This is the main difference from SSG, which you learn in the next lesson.
Data Transfer to Client
Without help, the browser would send the same HttpClient request again after hydration. provideClientHydration() prevents this:
ServerHttpClient gets postsng-stateanswer stored in the HTMLBrowserHttpClient checks ng-stateNo 2nd requestanswer reusedThe same GET request is sent only once: on the server.
HttpClient answers move from the server to the browser inside the page.
- On the server, Angular stores every
HttpClientanswer in the HTML, inside a<script id="ng-state">element. - In the browser,
HttpClientfinds the stored answer and uses it. No second request is sent.
<script id="ng-state" type="application/json">
{"…": {"b": [{"slug": "what-is-ssr", "title": "What is SSR?"}]}}
</script>
This works automatically for GET requests. Requests with other methods are always sent again.
Advantages and Limitations of SSR
Advantages:
- Fast first view, also on slow devices.
- Full HTML for search engines and link previews.
- Always current data, rendered for each request.
Limitations:
- Needs a Node.js server. You cannot use a simple file hosting service.
- Server cost. The server works for every request.
- More rules for your code. Browser-only code needs
afterNextRender()orisPlatformBrowser(). - Slower response than a ready file. The server needs time to render each page.
| Point | CSR | SSR |
|---|---|---|
| First HTML | Empty | Full content |
| First view | Slow | Fast |
| Search engines | Weak | Strong |
| Hosting | Any file host | A Node.js server |
| Browser-only code | Works everywhere | Needs afterNextRender() |
Capstone Project: Blog Portal
You now build a Blog Portal: a list of posts and a page for each post, rendered on the server.
Build Blog Listing Pages
PostList requests /api/posts and shows each post with a link to its page.
Build Blog Details Pages
PostDetail shows one post. The route posts/:slug gives the slug to its @Input(). The input uses a setter: a method written as @Input() set slug(value) that runs each time the router gives a new value. It requests the post with this slug.
Implement Server Rendering
In the downloadable project, every route uses RenderMode.Server in app.routes.server.ts, and server.ts runs the app.
Fetch Content on the Server
BlogApiService uses HttpClient. On the server, Angular waits for the answers, and the HTML contains the posts. The answers are transferred to the browser.
Configure Dynamic Routes
posts/:slug is a dynamic route: one route for every post. With SSR, a new post works at once, because the server renders the page for each request.
Handle Loading and Error States
Each page has a loading and an error signal. An unknown slug returns 404 from the API, and the page shows "This post does not exist."
Capstone: Blog Portal
import { ApplicationConfig, Component, Injectable, Input, inject, signal } from '@angular/core';
import {
HttpClient, HttpErrorResponse, HttpHandlerFn, HttpRequest, HttpResponse,
provideHttpClient, withInterceptors,
} from '@angular/common/http';
import { RouterLink, RouterOutlet, Routes, provideRouter, withComponentInputBinding } from '@angular/router';
import { delay, mergeMap, of, throwError, timer } from 'rxjs';
export interface Post {
slug: string;
title: string;
author: string;
body: string;
}
// ── API service ──
@Injectable({ providedIn: 'root' })
export class BlogApiService {
private http = inject(HttpClient);
getPosts() {
return this.http.get<Post[]>('/api/posts');
}
getPost(slug: string) {
return this.http.get<Post>(`/api/posts/${slug}`);
}
}
// ── Listing page ──
@Component({
selector: 'app-post-list',
imports: [RouterLink],
template: `
<h4>Latest posts</h4>
@if (loading()) {
<p>Loading posts...</p>
} @else {
@for (post of posts(); track post.slug) {
<p><a [routerLink]="['/posts', post.slug]">{{ post.title }}</a> by {{ post.author }}</p>
}
<p><a routerLink="/posts/missing-post">A broken link</a></p>
}
`,
})
export class PostList {
posts = signal<Post[]>([]);
loading = signal(true);
constructor() {
inject(BlogApiService).getPosts().subscribe(list => {
this.posts.set(list);
this.loading.set(false);
});
}
}
// ── Details page ──
@Component({
selector: 'app-post-detail',
imports: [RouterLink],
template: `
<p><a routerLink="/">← All posts</a></p>
@if (loading()) {
<p>Loading post...</p>
} @else if (error()) {
<p>⚠ {{ error() }}</p>
} @else if (post(); as post) {
<h4>{{ post.title }}</h4>
<p>{{ post.body }}</p>
}
`,
})
export class PostDetail {
private api = inject(BlogApiService);
post = signal<Post | null>(null);
loading = signal(true);
error = signal('');
@Input() set slug(value: string) {
this.loading.set(true);
this.error.set('');
this.api.getPost(value).subscribe({
next: post => {
this.post.set(post);
this.loading.set(false);
},
error: (err: HttpErrorResponse) => {
this.error.set(err.status === 404 ? 'This post does not exist.' : 'Could not load the post.');
this.loading.set(false);
},
});
}
}
// ── Routes and configuration ──
const routes: Routes = [
{ path: '', component: PostList },
{ path: 'posts/:slug', component: PostDetail },
];
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withInterceptors([practiceServer])),
],
};
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: `<b>📝 Blog Portal</b><router-outlet />`,
})
export class App {}
// ─── Practice server ──────────────────────────────────────────────
// Answers the blog API inside the browser, like in the API Integration lesson.
const POSTS = [
{ slug: 'what-is-ssr', title: 'What is SSR?', author: 'Asha Rao', body: 'The server builds the HTML of the page.' },
{ slug: 'hydration', title: 'Hydration Explained', author: 'Ravi Kumar', body: 'Angular reuses the HTML from the server.' },
];
function practiceServer(req: HttpRequest<unknown>, next: HttpHandlerFn) {
if (req.url === '/api/posts') {
return of(new HttpResponse({ status: 200, body: POSTS })).pipe(delay(300));
}
const post = POSTS.find(p => req.url === `/api/posts/${p.slug}`);
if (post) {
return of(new HttpResponse({ status: 200, body: post })).pipe(delay(300));
}
return timer(300).pipe(mergeMap(() => throwError(() => new HttpErrorResponse({ status: 404, statusText: 'Not Found' }))));
}
Open a post, go back, and open the broken link to see the error state.
Download Project Files
Download SSR Project Source Code
Download angular-blog-portal-ssr.zip
The ZIP file contains the Blog Portal as a complete Angular SSR project, with the blog API in server.ts.
| File | What it contains |
|---|---|
src/server.ts | The Express server: the blog API and the Angular rendering |
src/server-data/posts.ts | The blog posts |
src/app/app.routes.server.ts | RenderMode.Server for every route |
src/app/app.config.ts | Router, HttpClient and hydration with event replay |
src/app/blog-api.service.ts | The requests to the blog API |
src/app/pages/ | The listing page, the details page and the "not found" page |
src/app/absolute-url.interceptor.ts | Adds the server address to /api URLs, which the server needs |
Project Setup Instructions
You need Node.js 20.19 or newer.
- Unzip the file and open a terminal in the
blog-portalfolder. Install the packages:
npm installStart the development server. It runs SSR and the API together:
npm start- Open
http://localhost:4200.
To run the production version:
npm run build
npm run serve:ssr:blog-portal
Then open http://localhost:4000. Open the page source (right-click → View Page Source): the HTML already contains the posts and a <script id="ng-state"> element with the transferred data.
Common Mistakes
| Mistake | What you see | Fix |
|---|---|---|
Using window or localStorage in a constructor | The server crashes: window is not defined | Use afterNextRender() or isPlatformBrowser() |
| Different content on the server and in the browser | Hydration error in the console, and the page flashes | Render the same content; change it later in afterNextRender() |
No provideClientHydration() | The page flashes, and data is requested twice | Add provideClientHydration() to app.config.ts |
Relative URLs such as /api/posts on the server | The request fails on the server, because there is no current page | Add the server address with an interceptor |
| Deploying an SSR app to a file host | The server part does not run | Use a host that runs Node.js |
Check Yourself
Exercises
What to Remember
- In SSR, a Node.js server renders each request into full HTML, so the first view is fast and search engines see the content.
RenderMode.Serverinapp.routes.server.tsturns it on for a route. - Hydration reuses the server HTML in the browser;
withEventReplay()keeps early clicks. Browser-only code goes intoafterNextRender()or behindisPlatformBrowser(). HttpClientrequests run on the server, and their answers are transferred to the browser, so they are not sent twice.
Next: Static Site Generation, how to create the HTML of every page once, at build time.