Skip to main content

Overview

Server-Side Rendering (SSR) generates your Angular application’s HTML on the server, providing faster initial page loads, better SEO, and improved performance on low-powered devices.

Benefits of SSR

Better SEO

Search engines can crawl fully-rendered pages with content.

Faster First Paint

Users see content faster before JavaScript loads and executes.

Social Sharing

Preview images and metadata work correctly on social platforms.

Low-End Devices

Better performance on devices with limited processing power.

Setup

Adding SSR to Existing Application

Use Angular CLI to add SSR support:
ng add @angular/ssr
This command:
  • Installs necessary dependencies
  • Creates server-side configuration files
  • Updates angular.json with server build configuration
  • Adds server entry point files

Manual Setup

For more control, set up SSR manually:
// server.ts
import 'zone.js/node';
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr';
import * as 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(browserDistFolder, 'index.html');

const app = express();
const commonEngine = new CommonEngine();

// Serve static files
app.get('*.*', express.static(browserDistFolder, {
  maxAge: '1y'
}));

// All regular routes use the Angular engine
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));
});

export default app;

Server Configuration

Main Server Bootstrap

// 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;

Server App Configuration

// app.config.server.ts
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);

Rendering Applications

Using renderApplication

import { renderApplication } from '@angular/platform-server';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';

async function render(url: string, document: string): Promise<string> {
  const html = await renderApplication(AppComponent, {
    appId: 'my-app',
    document,
    url,
    providers: [
      ...appConfig.providers
    ]
  });

  return html;
}

Using renderModule (NgModule)

import { renderModule } from '@angular/platform-server';
import { AppServerModule } from './app/app.server.module';

async function render(url: string, document: string): Promise<string> {
  const html = await renderModule(AppServerModule, {
    document,
    url,
    extraProviders: [
      // Additional server-specific providers
    ]
  });

  return html;
}

Platform-Specific Code

Detecting Platform

Use platform checks to run code conditionally.
import { Component, Inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';

@Component({
  selector: 'app-platform-aware',
  template: `
    <div>
      <p>Platform: {{ platform }}</p>
      <p *ngIf="isBrowser">Window width: {{ windowWidth }}</p>
    </div>
  `
})
export class PlatformAwareComponent {
  platform: string;
  isBrowser: boolean;
  windowWidth?: number;

  constructor(@Inject(PLATFORM_ID) private platformId: object) {
    this.isBrowser = isPlatformBrowser(platformId);
    this.platform = this.isBrowser ? 'Browser' : 'Server';

    if (this.isBrowser) {
      this.windowWidth = window.innerWidth;
    }
  }
}

Conditional Browser APIs

import { Component, Inject, PLATFORM_ID, OnInit } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Component({
  selector: 'app-local-storage',
  template: `
    <div>
      <input 
        [(ngModel)]="value" 
        (input)="save()"
        placeholder="Type something..."
      >
      <p>Stored value: {{ storedValue }}</p>
    </div>
  `
})
export class LocalStorageComponent implements OnInit {
  value = '';
  storedValue = '';
  private isBrowser: boolean;

  constructor(@Inject(PLATFORM_ID) platformId: object) {
    this.isBrowser = isPlatformBrowser(platformId);
  }

  ngOnInit(): void {
    if (this.isBrowser) {
      this.storedValue = localStorage.getItem('myValue') || '';
      this.value = this.storedValue;
    }
  }

  save(): void {
    if (this.isBrowser) {
      localStorage.setItem('myValue', this.value);
      this.storedValue = this.value;
    }
  }
}

HTTP and Data Transfer

TransferState

Avoid duplicate HTTP requests by transferring data from server to client.
import { Component, OnInit } from '@angular/core';
import { 
  makeStateKey, 
  TransferState 
} from '@angular/platform-browser';
import { HttpClient } from '@angular/common/http';

interface User {
  id: number;
  name: string;
  email: string;
}

const USERS_KEY = makeStateKey<User[]>('users');

@Component({
  selector: 'app-users',
  template: `
    <div *ngIf="users">
      <h2>Users</h2>
      <ul>
        <li *ngFor="let user of users">
          {{ user.name }} - {{ user.email }}
        </li>
      </ul>
    </div>
    <p *ngIf="!users">Loading...</p>
  `
})
export class UsersComponent implements OnInit {
  users?: User[];

  constructor(
    private http: HttpClient,
    private transferState: TransferState
  ) {}

  ngOnInit(): void {
    // Check if data exists in transfer state
    const cachedUsers = this.transferState.get(USERS_KEY, null);

    if (cachedUsers) {
      // Use cached data from server
      this.users = cachedUsers;
    } else {
      // Fetch data and store for transfer
      this.http.get<User[]>('/api/users').subscribe(users => {
        this.users = users;
        this.transferState.set(USERS_KEY, users);
      });
    }
  }
}

HTTP Interceptor for TransferState

import { Injectable } from '@angular/core';
import {
  HttpInterceptor,
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpResponse
} from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { TransferState, makeStateKey } from '@angular/platform-browser';

@Injectable()
export class TransferStateInterceptor implements HttpInterceptor {
  constructor(private transferState: TransferState) {}

  intercept(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    // Only cache GET requests
    if (req.method !== 'GET') {
      return next.handle(req);
    }

    const key = makeStateKey(req.url);
    const cachedResponse = this.transferState.get(key, null);

    if (cachedResponse) {
      // Remove from transfer state after using
      this.transferState.remove(key);
      return of(new HttpResponse({ body: cachedResponse, status: 200 }));
    }

    return next.handle(req).pipe(
      tap(event => {
        if (event instanceof HttpResponse) {
          this.transferState.set(key, event.body);
        }
      })
    );
  }
}

Pre-rendering (SSG)

Generate static pages at build time for even better performance.

Configuration

// angular.json
{
  "projects": {
    "my-app": {
      "architect": {
        "prerender": {
          "builder": "@angular-devkit/build-angular:prerender",
          "options": {
            "routes": [
              "/",
              "/about",
              "/contact",
              "/blog/post-1",
              "/blog/post-2"
            ]
          },
          "configurations": {
            "production": {
              "browserTarget": "my-app:build:production",
              "serverTarget": "my-app:server:production"
            }
          }
        }
      }
    }
  }
}

Dynamic Route Discovery

// routes.txt or routes generator
import { writeFileSync } from 'fs';
import { join } from 'path';

interface BlogPost {
  slug: string;
}

async function generateRoutes(): Promise<void> {
  // Fetch dynamic routes from API or database
  const posts: BlogPost[] = await fetchBlogPosts();
  
  const routes = [
    '/',
    '/about',
    '/contact',
    ...posts.map(post => `/blog/${post.slug}`)
  ];

  // Write routes to file
  const routesFile = join(__dirname, 'routes.txt');
  writeFileSync(routesFile, routes.join('\n'));
}

async function fetchBlogPosts(): Promise<BlogPost[]> {
  // Fetch from your API or database
  return [];
}

generateRoutes();

SEO Optimization

Meta Tags Service

import { Injectable } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';

@Injectable({ providedIn: 'root' })
export class SeoService {
  constructor(
    private meta: Meta,
    private title: Title
  ) {}

  updateMetaTags(config: {
    title: string;
    description: string;
    image?: string;
    url?: string;
    type?: string;
  }): void {
    // Update title
    this.title.setTitle(config.title);

    // Update meta tags
    this.meta.updateTag({ name: 'description', content: config.description });
    
    // Open Graph tags
    this.meta.updateTag({ property: 'og:title', content: config.title });
    this.meta.updateTag({ property: 'og:description', content: config.description });
    this.meta.updateTag({ property: 'og:type', content: config.type || 'website' });
    
    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 tags
    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 });
    }
  }
}

Using SEO Service

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { SeoService } from './seo.service';

interface BlogPost {
  title: string;
  description: string;
  image: string;
  slug: string;
}

@Component({
  selector: 'app-blog-post',
  template: `
    <article *ngIf="post">
      <h1>{{ post.title }}</h1>
      <img [src]="post.image" [alt]="post.title">
      <p>{{ post.description }}</p>
    </article>
  `
})
export class BlogPostComponent implements OnInit {
  post?: BlogPost;

  constructor(
    private route: ActivatedRoute,
    private seo: SeoService
  ) {}

  ngOnInit(): void {
    this.route.data.subscribe(data => {
      this.post = data['post'];
      
      if (this.post) {
        this.seo.updateMetaTags({
          title: this.post.title,
          description: this.post.description,
          image: this.post.image,
          url: `https://example.com/blog/${this.post.slug}`,
          type: 'article'
        });
      }
    });
  }
}

Best Practices

Use TransferState

Avoid duplicate HTTP requests by transferring data from server to client.

Lazy Load Heavy Components

Defer loading of non-critical components to improve initial render time.

Handle Platform Differences

Always check platform before accessing browser-only APIs.

Optimize Images

Use responsive images and lazy loading for better performance.
Avoid using browser-specific APIs like window, document, localStorage without platform checks. These will cause errors during server-side rendering.

Additional Resources

Build docs developers (and LLMs) love