DEV Community

Cover image for Angular SSR and hydration -the complete 2026 guide
Abdul Rashid
Abdul Rashid

Posted on

Angular SSR and hydration -the complete 2026 guide

Server-side rendering in Angular has gone through a quiet revolution over the last two years. What used to require a separate @nguniversal/express-engine package, manual transfer state wiring, and careful platform guards is now built directly into the Angular CLI - first-class, opinionated, and dramatically simpler.

But SSR also introduces a category of bugs that don't exist in purely client-rendered apps. Hydration mismatches, platform-specific API calls, broken transfer state, and incorrect rendering strategies can ship silently and degrade performance in ways that are hard to trace.

This guide covers the full picture: how Angular's SSR and hydration work under the hood, how to configure per-route rendering strategies, how to diagnose and fix hydration mismatches, and how to deploy to Node, Deno, and Edge runtimes.

How Angular Universal became Angular SSR

Before Angular 17, server-side rendering meant installing @nguniversal/express-engine as a separate community package, scaffolding a custom server entry point, and manually coordinating TransferState to avoid double-fetching data.

Angular 17 changed this. SSR is now part of the core Angular CLI. When you create a new project with ng new --ssr, the scaffolding produces a server.ts file, a configured app.config.server.ts, and an angular.json build target that handles the server bundle automatically.

The @nguniversal packages still work but are deprecated. New projects should use the built-in SSR support exclusively.

Non-destructive hydration - how it works in Angular 17+

The most important SSR improvement in Angular 17 was non-destructive hydration. Understanding what it solved requires understanding what came before.

The old destructive hydration model

In earlier Angular SSR, the server rendered HTML and sent it to the browser. The browser displayed that HTML immediately - giving the user something to look at. But then Angular bootstrapped on the client, and its first act was to destroy the server-rendered DOM entirely and recreate it from scratch in JavaScript.

This caused a visible flash: the server-rendered content disappeared briefly as Angular replaced it. It also meant the browser did double the work - rendering the HTML from the server, then discarding it and rendering it again from JavaScript.

Non-destructive hydration

Angular 17's non-destructive hydration keeps the server-rendered DOM. Instead of destroying and recreating it, Angular walks the existing DOM, attaches event listeners, and maps component state onto the nodes that are already on the page.

The result is:

  • No content flash on hydration
  • Significantly less client-side JavaScript work
  • Better Core Web Vitals scores, especially Cumulative Layout Shift (CLS) and Largest Contentful Paint (LCP)

To enable it in an existing app, add provideClientHydration() to your app providers:

// app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideClientHydration()
  ]
};
Enter fullscreen mode Exit fullscreen mode

For new apps scaffolded with ng new --ssr, this is already configured.


Per-route rendering strategy in angular.json

One of the most powerful features in Angular 17+ SSR is the ability to assign a different rendering mode to each route. Not every route in your app should be server-rendered - and Angular now lets you be explicit about this.

The three rendering modes

server - rendered on the server on every request. Ideal for pages with user-specific content, real-time data, or authentication-gated views.

prerender - rendered at build time and served as a static HTML file. Ideal for content that rarely changes: marketing pages, blog posts, documentation.

client - skips SSR entirely, rendered in the browser only. Ideal for auth-gated dashboards, admin panels, and anything that depends on browser APIs at startup.

Configuring rendering modes

In angular.json, under the prerender options in your build target:

{
  "projects": {
    "my-app": {
      "architect": {
        "build": {
          "options": {
            "server": "src/main.server.ts",
            "prerender": {
              "routesFile": "routes.txt"
            },
            "ssr": {
              "entry": "server.ts"
            }
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

And in your routes configuration, use the renderMode property:

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

export const serverRoutes: ServerRoute[] = [
  {
    path: 'blog/:slug',
    renderMode: RenderMode.Prerender,
    async getPrerenderParams() {
      const posts = await fetch('https://api.example.com/posts').then(r => r.json());
      return posts.map((post: { slug: string }) => ({ slug: post.slug }));
    }
  },
  {
    path: 'about',
    renderMode: RenderMode.Prerender
  },
  {
    path: 'dashboard',
    renderMode: RenderMode.Client
  },
  {
    path: '**',
    renderMode: RenderMode.Server
  }
];
Enter fullscreen mode Exit fullscreen mode

The getPrerenderParams function on parameterised routes tells Angular which parameter values to prerender at build time. In the example above, every blog post slug is fetched from the API and rendered to static HTML during the build.

Why this matters

The hybrid approach is almost always the right answer for production apps. A typical app might have:

  • A marketing homepage → Prerender (zero compute cost, instant delivery from CDN)
  • A blog → Prerender (content changes infrequently, deploy triggers a rebuild)
  • A product listing page → Server (inventory changes in real time)
  • A user dashboard → Client (user-specific, auth-gated, no SEO value)

Assigning Client to your dashboard avoids SSR running on every authenticated request, which is both a performance and a cost saving. Assigning Prerender to your blog eliminates server compute entirely for those routes.


Fixing hydration mismatches - Date, Math.random, browser APIs

Hydration mismatches are the most frustrating class of SSR bug. They occur when the HTML Angular renders on the server differs from what Angular tries to hydrate on the client. Angular detects the mismatch, logs a warning, and falls back to destructive hydration for the affected component — losing all the performance benefits.

Why mismatches happen

The server and browser execute the same Angular code but in different environments. Three categories of code produce different output in each environment:

1. Time and date values

new Date() returns the server's current time when rendered on the server, and the browser's current time when hydrated on the client. If your template renders a date directly, these values will differ.

// Problematic
protected today = new Date().toLocaleDateString();
Enter fullscreen mode Exit fullscreen mode

Fix: fetch dates from a deterministic source, or use TransferState to pass the server-computed value to the client (covered below).

2. Random values

Math.random() generates a different value on the server and on the client. Any template that renders a random value - a random greeting, a randomly selected feature highlight, a randomly ordered list — will produce a mismatch.

// Problematic
protected featuredItem = items[Math.floor(Math.random() * items.length)];
Enter fullscreen mode Exit fullscreen mode

Fix: seed random selection server-side and transfer the result to the client, or make the selection client-only using isPlatformBrowser.

3. Browser-only APIs

localStorage, sessionStorage, window, document, and navigator do not exist in the Node.js environment. Calling them during SSR throws a ReferenceError and breaks rendering.

// Problematic - crashes during SSR
protected theme = localStorage.getItem('theme') ?? 'light';
Enter fullscreen mode Exit fullscreen mode

Fix: guard with isPlatformBrowser.


isPlatformBrowser - guarding browser-only code

isPlatformBrowser is the standard Angular API for code that should only run in the browser. Inject PLATFORM_ID and use it to guard any browser-specific logic:

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

@Component({ ... })
export class ThemeService implements OnInit {
  constructor(@Inject(PLATFORM_ID) private platformId: Object) {}

  ngOnInit() {
    if (isPlatformBrowser(this.platformId)) {
      const theme = localStorage.getItem('theme') ?? 'light';
      document.documentElement.setAttribute('data-theme', theme);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

With the modern inject() function, this becomes less ceremonious:

import { inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Injectable({ providedIn: 'root' })
export class ThemeService {
  private isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

  applyTheme() {
    if (!this.isBrowser) return;
    const theme = localStorage.getItem('theme') ?? 'light';
    document.documentElement.setAttribute('data-theme', theme);
  }
}
Enter fullscreen mode Exit fullscreen mode

afterNextRender and afterRender

Angular 16+ introduced afterNextRender and afterRender as browser-safe lifecycle hooks. Code inside these callbacks is guaranteed to run only in the browser, and only after the component has been rendered to the DOM:

import { Component, afterNextRender, ElementRef, viewChild } from '@angular/core';

@Component({ ... })
export class ChartComponent {
  private canvasRef = viewChild<ElementRef>('canvas');

  constructor() {
    afterNextRender(() => {
      // Safe: runs only in browser, after DOM is ready
      const ctx = this.canvasRef()?.nativeElement.getContext('2d');
      // initialise Chart.js here
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

This is now the preferred pattern for DOM manipulation and browser API access over ngAfterViewInit, which still runs during SSR (without a DOM).


TransferState - avoiding double data fetching

Without TransferState, an SSR app fetches data twice: once on the server to render the initial HTML, and again on the client when Angular bootstraps and runs ngOnInit. The user sees the correct content immediately, but the app makes a redundant network request in the background.

TransferState solves this by serialising server-fetched data into the HTML and making it available on the client without a new request.

Using TransferState with HttpClient

Angular's HttpClient handles this automatically when you configure it correctly:

// app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(
      withFetch(),
      withRequestTransferCache() // enables automatic TransferState for HTTP requests
    ),
    provideClientHydration()
  ]
};
Enter fullscreen mode Exit fullscreen mode

With withRequestTransferCache(), any HTTP request made during server-side rendering is automatically serialised into the page. When the client bootstraps, the same request is served from the transfer cache instead of hitting the network again.

Manual TransferState for custom data

For data that doesn't go through HttpClient, use TransferState directly:

import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { TransferState, makeStateKey } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

const CONFIG_KEY = makeStateKey<AppConfig>('appConfig');

@Injectable({ providedIn: 'root' })
export class ConfigService {
  private transferState = inject(TransferState);
  private isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

  async getConfig(): Promise<AppConfig> {
    if (this.isBrowser && this.transferState.hasKey(CONFIG_KEY)) {
      return this.transferState.get(CONFIG_KEY, null)!;
    }

    const config = await fetch('/api/config').then(r => r.json());

    if (!this.isBrowser) {
      this.transferState.set(CONFIG_KEY, config);
    }

    return config;
  }
}
Enter fullscreen mode Exit fullscreen mode

SSG with ng build --prerender

Static site generation prerenders routes at build time. The output is plain HTML files that can be served from a CDN with no server compute at request time.

Basic prerendering

For routes without parameters, no additional configuration is needed. Angular discovers them from your routes array automatically:

ng build --prerender
Enter fullscreen mode Exit fullscreen mode

The build output contains an index.html for each prerendered route:

dist/
  app/
    browser/
      index.html          (home route)
      about/
        index.html
      blog/
        index.html
Enter fullscreen mode Exit fullscreen mode

Parameterised routes

For routes like /blog/:slug, Angular needs to know which parameter values to prerender. Provide them via getPrerenderParams in your server routes configuration (shown earlier), or via a routes.txt file:

/blog/angular-signals-guide
/blog/angular-lazy-loading-2025
/blog/ngrx-signal-store
Enter fullscreen mode Exit fullscreen mode

Point to it in angular.json:

"prerender": {
  "routesFile": "routes.txt"
}
Enter fullscreen mode Exit fullscreen mode

Deploying Angular SSR to Node, Deno, and Edge runtimes

Node.js (Express)

The default Angular SSR scaffold produces an Express server. Build and run it:

ng build
node dist/app/server/server.mjs
Enter fullscreen mode Exit fullscreen mode

For production, serve it behind a reverse proxy (nginx, Caddy) and use a process manager like PM2:

pm2 start dist/app/server/server.mjs --name angular-ssr
Enter fullscreen mode Exit fullscreen mode

Deno Deploy

Angular 17.2+ added experimental support for Deno. Set the outputMode to server and the engine to deno in your server configuration. The output is a Deno-compatible module deployable directly to Deno Deploy.

Edge runtimes (Cloudflare Workers, Vercel Edge)

Edge deployment is the highest-performance option for SSR: your server runs at a CDN node close to the user, with cold start times measured in milliseconds rather than seconds.

Angular's SSR output is a standard ES module. Cloudflare Workers and Vercel Edge Functions can import it directly. The key constraint is that edge runtimes do not support all Node.js APIs - fs, path, and other Node-specific modules are unavailable. Any server-side code that uses these must be replaced with fetch-based equivalents.

// server.ts for Cloudflare Workers
import { createRequestHandler } from '@angular/ssr/node';
import { AppServerModule } from './src/app/app.server.module';

export default {
  fetch(request: Request, env: Env) {
    const handler = createRequestHandler({ bootstrap: AppServerModule });
    return handler(request);
  }
};
Enter fullscreen mode Exit fullscreen mode

Performance profiling SSR vs CSR with Lighthouse

The real-world impact of SSR is best measured with Lighthouse, which captures the metrics that matter to users and to search ranking.

Metrics to compare

Run Lighthouse on the same route with SSR enabled and disabled and compare:

  • Largest Contentful Paint (LCP) - SSR typically improves this significantly because meaningful HTML is in the initial response rather than loaded by JavaScript
  • First Contentful Paint (FCP) - similar improvement
  • Total Blocking Time (TBT) - can increase with SSR if hydration adds significant JavaScript execution; watch this carefully
  • Cumulative Layout Shift (CLS) - non-destructive hydration should keep this near zero; a high CLS score often points to a hydration mismatch

Running a fair comparison

# Build with SSR
ng build

# Serve with SSR
node dist/app/server/server.mjs

# Build without SSR (client only)
ng build --no-server

# Serve statically
npx http-server dist/app/browser
Enter fullscreen mode Exit fullscreen mode

Run Lighthouse in an incognito window on each, in mobile simulation mode (the default), with CPU throttling enabled. The mobile + throttled profile reflects real-world conditions for the largest portion of your user base.


Summary

Angular SSR in 2026 is production-ready, ergonomic, and powerful. The combination of non-destructive hydration, per-route rendering strategies, automatic HTTP transfer state, and edge runtime support puts Angular in a strong position for building apps that are fast for every user on every connection.

The most common mistakes to avoid:

  • Calling browser APIs (localStorage, window) without isPlatformBrowser guards
  • Rendering non-deterministic values (dates, random numbers) without transferring them via TransferState
  • Applying Server rendering mode to auth-gated routes that have no SEO value
  • Skipping the Lighthouse comparison before and after enabling SSR

Used correctly, SSR is one of the most impactful improvements you can make to an Angular app's perceived performance and search visibility.

Found this useful? Follow for more mid-to-expert Angular content every week. Next up: Angular micro-frontends with Module Federation — architecture, setup, and the cases where you should not use it.

Top comments (0)