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:

FileJob
src/server.tsThe Node.js server (Express). It receives requests and calls Angular
src/main.server.tsStarts the app on the server
src/app/app.config.server.tsProviders used only on the server
src/app/app.routes.server.tsThe 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:

  1. It finds the route that matches the URL.
  2. It creates the components of that route, the same way as in the browser.
  3. It waits until the components have their data, for example from HttpClient.
  4. It turns the rendered components into an HTML text.
  5. It sends this HTML to the browser.

Request-Response Lifecycle

  1. The user opens https://myblog.com/posts/hydration.
  2. The request reaches server.ts.
  3. Angular renders the post page on the server.
  4. The response, the full HTML, goes back to the browser.
  5. The browser shows the HTML and downloads the JavaScript.
  6. 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, document and localStorage
  • 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.

  1. On the server, Angular stores every HttpClient answer in the HTML, inside a <script id="ng-state"> element.
  2. In the browser, HttpClient finds 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() or isPlatformBrowser().
  • Slower response than a ready file. The server needs time to render each page.
PointCSRSSR
First HTMLEmptyFull content
First viewSlowFast
Search enginesWeakStrong
HostingAny file hostA Node.js server
Browser-only codeWorks everywhereNeeds 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.

FileWhat it contains
src/server.tsThe Express server: the blog API and the Angular rendering
src/server-data/posts.tsThe blog posts
src/app/app.routes.server.tsRenderMode.Server for every route
src/app/app.config.tsRouter, HttpClient and hydration with event replay
src/app/blog-api.service.tsThe requests to the blog API
src/app/pages/The listing page, the details page and the "not found" page
src/app/absolute-url.interceptor.tsAdds the server address to /api URLs, which the server needs

Project Setup Instructions

You need Node.js 20.19 or newer.

  1. Unzip the file and open a terminal in the blog-portal folder.
  2. Install the packages:

    npm install
  3. Start the development server. It runs SSR and the API together:

    npm start
  4. 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

MistakeWhat you seeFix
Using window or localStorage in a constructorThe server crashes: window is not definedUse afterNextRender() or isPlatformBrowser()
Different content on the server and in the browserHydration error in the console, and the page flashesRender the same content; change it later in afterNextRender()
No provideClientHydration()The page flashes, and data is requested twiceAdd provideClientHydration() to app.config.ts
Relative URLs such as /api/posts on the serverThe request fails on the server, because there is no current pageAdd the server address with an interceptor
Deploying an SSR app to a file hostThe server part does not runUse 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.Server in app.routes.server.ts turns it on for a route.
  • Hydration reuses the server HTML in the browser; withEventReplay() keeps early clicks. Browser-only code goes into afterNextRender() or behind isPlatformBrowser().
  • HttpClient requests 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.