← Back to DevBytes

Server-Side Rendering with Angular: SSR, SSG, ISR

Server-Side Rendering with Angular: SSR, SSG, and ISR Explained

Modern web applications must balance performance, SEO, and user experience. Angular has evolved significantly in this space, and with the introduction of Angular 17's new SSR capabilities and the @angular/ssr package, developers now have access to multiple rendering strategies. This tutorial covers three of the most important ones: Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR).

What Is Server-Side Rendering?

By default, Angular is a client-side framework. The browser downloads a minimal HTML file and a large JavaScript bundle, then renders the application in the browser. This approach can hurt performance metrics like First Contentful Paint (FCP) and Largest Contentful Paint (LCP), and it makes SEO difficult because crawlers may not execute JavaScript.

Server-side rendering solves this by generating the full HTML on the server before sending it to the client. The user sees content immediately, and search engines can index the page without executing JavaScript. Once the JavaScript loads, Angular "hydrates" the static HTML, attaching event listeners and turning it into a fully interactive application.

The Three Rendering Strategies

Why Rendering Strategy Matters

Choosing the right strategy affects three critical areas: performance, SEO, and infrastructure cost. SSR provides fresh content but requires a running Node.js server. SSG is blazing fast and can be served from any CDN, but rebuilds are needed for content changes. ISR offers a middle ground, combining the speed of static files with the freshness of server rendering.

For an e-commerce site, you might use SSG for the homepage and about page, ISR for product pages that update occasionally, and SSR for the shopping cart and checkout flow. Angular's hybrid rendering model lets you mix these strategies within a single application using route-level configuration.

Setting Up SSR in Angular

Starting with Angular 17, SSR support is built into the Angular CLI. You can add it to an existing project or enable it when creating a new one.

Creating a New Project with SSR

ng new my-ssr-app --ssr
cd my-ssr-app

Adding SSR to an Existing Project

ng add @angular/ssr

This command modifies your angular.json, adds a server entry point, and creates the necessary server configuration files. You will see a new src/main.server.ts file and an app.config.server.ts file for server-side providers.

Understanding the Generated Server Configuration

// src/app/app.config.server.ts
import { mergeApplicationConfig, ApplicationConfig } 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);

The server configuration merges with your client configuration and adds the provideServerRendering() provider. This enables Angular to render your application on the server using the same components you use on the client.

Configuring Route-Level Rendering Strategies

Angular 19 introduced the provideServerRouting() function and the ServerRoute interface, which let you define rendering strategies per route. This is the key to mixing SSR, SSG, and ISR in one application.

Defining Routes with Different Strategies

// src/app/app.routes.server.ts
import { ServerRoute } from '@angular/ssr';
import { render } from './routes/render';

export const serverRoutes: ServerRoute[] = [
  {
    path: '',
    renderMode: render.prerender
  },
  {
    path: 'blog/:id',
    renderMode: render.prerender,
    getPrerenderParams: async () => {
      const posts = await fetch('https://api.example.com/posts').then(r => r.json());
      return posts.map((post: any) => ({ id: post.slug }));
    }
  },
  {
    path: 'products/:id',
    renderMode: render.server,
    serverTime: 60 // ISR: regenerate every 60 seconds
  },
  {
    path: 'dashboard',
    renderMode: render.server
  },
  {
    path: '**',
    renderMode: render.server
  }
];

In this example, the homepage is prerendered at build time (SSG), blog posts are prerendered with dynamic parameters fetched from an API, product pages use ISR with a 60-second regeneration window, and the dashboard uses full SSR on every request.

Registering Server Routes in Your App

// src/app/app.config.server.ts
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { provideServerRouting } from '@angular/ssr';
import { serverRoutes } from './app.routes.server';
import { appConfig } from './app.config';

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(),
    provideServerRouting(serverRoutes)
  ]
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

Working with Data Fetching on the Server

When rendering on the server, you need to fetch data before the HTML is generated. Angular provides the http function from @angular/ssr for this purpose, which works seamlessly with Angular's HttpClient.

Using HttpClient with SSR

// src/app/services/product.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

export interface Product {
  id: string;
  name: string;
  price: number;
  description: string;
}

@Injectable({ providedIn: 'root' })
export class ProductService {
  private http = inject(HttpClient);
  private apiUrl = 'https://api.example.com/products';

  getProduct(id: string): Observable<Product> {
    return this.http.get<Product>(`${this.apiUrl}/${id}`);
  }

  getProducts(): Observable<Product[]> {
    return this.http.get<Product[]>(this.apiUrl);
  }
}

Consuming Data in a Component

// src/app/pages/product-detail.component.ts
import { Component, inject, input } from '@angular/core';
import { ProductService, Product } from '../services/product.service';

@Component({
  selector: 'app-product-detail',
  template: `
    @if (product) {
      <article>
        <h1>{{ product.name }}</h1>
        <p class="price">${{ product.price }}</p>
        <p>{{ product.description }}</p>
      </article>
    } @else {
      <p>Loading...</p>
    }
  `
})
export class ProductDetailComponent {
  private productService = inject(ProductService);
  id = input.required<string>();
  product?: Product;

  constructor() {
    this.productService.getProduct(this.id()).subscribe(p => {
      this.product = p;
    });
  }
}

When this component renders on the server, Angular's HttpClient will automatically make the HTTP request server-side, wait for the response, and include the rendered content in the HTML. On the client, Angular will reuse the server-fetched data during hydration, avoiding a duplicate request.

Implementing SSG with Dynamic Prerendering

Static Site Generation is ideal for content-heavy pages. Angular's prerendering works by crawling your routes at build time and generating static HTML files for each one. For routes with parameters, you use the getPrerenderParams function to specify which parameter values to prerender.

Prerendering a Blog with Dynamic Routes

// src/app/app.routes.server.ts
import { ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  {
    path: '',
    renderMode: 'prerender'
  },
  {
    path: 'blog',
    renderMode: 'prerender'
  },
  {
    path: 'blog/:slug',
    renderMode: 'prerender',
    getPrerenderParams: async () => {
      const response = await fetch('https://api.example.com/posts');
      const posts = await response.json();
      return posts.map((post: any) => ({ slug: post.slug }));
    }
  },
  {
    path: '**',
    renderMode: 'server'
  }
];

During the build process, Angular will call getPrerenderParams, fetch the list of blog post slugs, and generate a static HTML file for each one. These files are placed in the dist/browser directory and can be served from any static file server or CDN.

Building the Application

ng build

The build output will include both the prerendered static files and the server bundle. Prerendered routes are output as .html files, while SSR routes are handled by the Node.js server.

Implementing ISR for Periodically Updated Content

Incremental Static Regeneration combines the speed of static files with the freshness of server rendering. When a request comes in, Angular serves the cached static version. After the specified time interval expires, the next request triggers a regeneration in the background, and the new version replaces the cache.

Configuring ISR on a Route

// src/app/app.routes.server.ts
import { ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  {
    path: 'products/:id',
    renderMode: 'server',
    serverTime: 300 // Regenerate every 5 minutes
  },
  {
    path: 'news',
    renderMode: 'server',
    serverTime: 60 // Regenerate every 1 minute
  }
];

The serverTime property specifies the cache duration in seconds. The first request generates the page and caches it. Subsequent requests within the time window receive the cached version instantly. After the window expires, the next request still receives the cached version but triggers a background regeneration.

How ISR Works Under the Hood

Angular's ISR implementation uses a cache layer on the server. When a request arrives, the server checks if a cached response exists and is still valid. If valid, it returns the cached HTML immediately. If expired, it returns the stale cache while asynchronously regenerating the page. This pattern, known as stale-while-revalidate, ensures users always get a fast response while content stays reasonably fresh.

Handling Browser-Only APIs

When running on the server, browser APIs like window, document, localStorage, and navigator are not available. Accessing them directly will cause server-side errors. Angular provides utilities to handle this safely.

Using isPlatformBrowser

// src/app/components/analytics.component.ts
import { Component, Inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Component({
  selector: 'app-analytics',
  template: '<div>Analytics loaded</div>'
})
export class AnalyticsComponent {
  constructor(@Inject(PLATFORM_ID) private platformId: object) {
    if (isPlatformBrowser(this.platformId)) {
      // Safe to use browser APIs here
      localStorage.setItem('visit', Date.now().toString());
      console.log('Running in browser');
    } else {
      console.log('Running on server');
    }
  }
}

Using afterNextRender for Browser-Only Logic

// src/app/components/chart.component.ts
import { Component, afterNextRender } from '@angular/core';

@Component({
  selector: 'app-chart',
  template: '<canvas id="myChart"></canvas>'
})
export class ChartComponent {
  constructor() {
    afterNextRender(() => {
      // This only runs in the browser, after hydration
      const canvas = document.getElementById('myChart') as HTMLCanvasElement;
      this.initializeChart(canvas);
    });
  }

  private initializeChart(canvas: HTMLCanvasElement): void {
    // Chart initialization logic
    console.log('Chart initialized');
  }
}

The afterNextRender function is the recommended approach for browser-only side effects. It runs only on the client after the component has been rendered and hydrated, making it safe for code that touches the DOM or browser APIs.

Setting Up the Production Server

For SSR and ISR to work in production, you need a Node.js server. Angular generates a server entry point that you can run with any Node.js-compatible server.

Basic Express Server Setup

// server.ts
import { AngularNodeAppEngine, createNodeRequestHandler, writeResponseToNodeResponse } from '@angular/ssr/node';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, resolve, join } from 'node:path';
import { readFile } from 'node:fs/promises';

export function app(): express.Express {
  const server = express();
  const serverDistFolder = dirname(fileURLToPath(import.meta.url));
  const browserDistFolder = resolve(serverDistFolder, '../browser');
  const angularApp = new AngularNodeAppEngine();

  server.use(express.static(browserDistFolder, {
    maxAge: '1y',
    index: false,
    redirect: false
  }));

  server.get('*.*', express.static(browserDistFolder, {
    maxAge: '1y'
  }));

  server.get('*', (req, res) => {
    angularApp.handle(req)
      .then(response => writeResponseToNodeResponse(response, res))
      .catch(err => {
        console.error(err);
        res.status(500).send('Internal Server Error');
      });
  });

  return server;
}

function run(): void {
  const port = process.env['PORT'] || 4000;
  const server = app();
  server.listen(port, () => {
    console.log(`Node Express server listening on http://localhost:${port}`);
  });
}

run();

Building and Running in Production

ng build
node dist/my-ssr-app/server/server.mjs

The build output includes the server bundle in dist/my-ssr-app/server/ and the browser bundle in dist/my-ssr-app/browser/. The server file handles both SSR routes and serves static files for prerendered routes.

Best Practices for Angular SSR

Optimize Transfer State

Angular automatically transfers server-fetched data to the client to avoid duplicate HTTP requests during hydration. However, you should be mindful of the payload size. Avoid fetching large datasets that are not immediately needed.

// Use transfer state for custom data
import { TransferState, makeStateKey } from '@angular/core';

const POSTS_KEY = makeStateKey<any[]>('posts');

@Component({...})
export class BlogComponent {
  constructor(private transferState: TransferState) {}

  loadPosts() {
    if (this.transferState.hasKey(POSTS_KEY)) {
      // Use transferred data from server
      return of(this.transferState.get(POSTS_KEY, []));
    }
    // Fetch from API
    return this.http.get('/api/posts').pipe(
      tap(posts => this.transferState.set(POSTS_KEY, posts))
    );
  }
}

Avoid Memory Leaks on the Server

Server-side Angular applications share a single Node.js process across requests. Memory leaks are more dangerous here because they accumulate across all users. Always unsubscribe from observables and clean up resources in ngOnDestroy.

// src/app/components/safe.component.ts
import { Component, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';

@Component({
  selector: 'app-safe',
  template: '<p>Safe component</p>'
})
export class SafeComponent implements OnDestroy {
  private subscriptions: Subscription[] = [];

  ngOnDestroy(): void {
    this.subscriptions.forEach(sub => sub.unsubscribe());
  }
}

Use Lazy Loading for Large Routes

Lazy loading reduces the initial bundle size and improves both server and client performance. Each lazy-loaded route is a separate chunk that loads only when needed.

// src/app/app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: 'admin',
    loadComponent: () => import('./pages/admin.component').then(m => m.AdminComponent)
  },
  {
    path: 'reports',
    loadChildren: () => import('./reports/reports.routes').then(m => m.REPORTS_ROUTES)
  }
];

Handle Errors Gracefully

Server errors should return appropriate HTTP status codes. Angular lets you customize error handling on the server to ensure crawlers and users receive correct responses.

// src/app/app.config.server.ts
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { provideServerRouting } from '@angular/ssr';
import { serverRoutes } from './app.routes.server';
import { appConfig } from './app.config';

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(),
    provideServerRouting(serverRoutes)
  ]
};

export const config = mergeApplicationConfig(appConfig, serverConfig);

Monitor Hydration Mismatches

Hydration mismatches occur when the server-rendered HTML differs from what the client expects. This can happen when using Date.now(), Math.random(), or other non-deterministic values during render. Always use afterNextRender for time-sensitive or random values.

// Bad: causes hydration mismatch
@Component({
  template: '<p>Current time: {{ now }}</p>'
})
export class BadComponent {
  now = new Date().toLocaleTimeString(); // Different on server and client
}

// Good: render time only on client
@Component({
  template: '<p>Current time: {{ now }}</p>'
})
export class GoodComponent {
  now = '';

  constructor() {
    afterNextRender(() => {
      this.now = new Date().toLocaleTimeString();
    });
  }
}

Deploying to Different Platforms

Deploying to Vercel

Vercel has native support for Angular SSR and ISR. Install the Vercel CLI and deploy:

npm i -g vercel
vercel

Vercel automatically detects Angular SSR and configures serverless functions for SSR routes. ISR routes with serverTime are automatically handled using Vercel's edge cache.

Deploying to Netlify

npm i -g netlify-cli
netlify deploy --build

Deploying with Docker

# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY package*.json ./
RUN npm ci --omit=dev
EXPOSE 4000
CMD ["node", "dist/my-ssr-app/server/server.mjs"]

Conclusion

Angular's modern rendering capabilities give developers the flexibility to choose the right strategy for each route. SSR delivers fresh, personalized content on every request; SSG provides maximum performance for static content; and ISR bridges the gap with stale-while-revalidate caching. By combining these strategies within a single application, you can optimize for both user experience and infrastructure cost. The key is to audit your routes, understand their data requirements, and assign the appropriate rendering mode. With proper attention to browser-only APIs, memory management, and hydration consistency, you can build Angular applications that are fast, SEO-friendly, and maintainable at scale.

— Ad —

Google AdSense will appear here after approval

← Back to all articles