Docs
/
Angular
Chapter 22

22 — SSR & SSG (Server-Side Rendering)

Why SSR?

Client-Side (SPA)SSRSSG
First paintSlow (JS download → render)Fast (HTML from server)Fastest (pre-built HTML)
SEOPoor (empty HTML)ExcellentExcellent
Dynamic dataYesYes (per request)No (build time only)
Server costNonePer requestNone (static hosting)
Best forDashboards, internal appsPublic pages, e-commerceBlogs, docs, marketing

Setup

ng add @angular/ssr

This generates:

  • src/app/app.config.server.ts — server-side providers
  • server.ts — Express server entry point
  • Updated angular.json with SSR builder

Configuration

app.config.ts (Client)

import { provideClientHydration, withEventReplay } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(withFetch()),
    provideClientHydration(withEventReplay()),   // Reuse server HTML
  ],
};

app.config.server.ts (Server)

import { provideServerRendering } from '@angular/platform-server';
import { provideServerRoutesConfig } from '@angular/ssr';
import { serverRoutes } from './app.routes.server';

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

app.routes.server.ts — Route Rendering Config

import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  // SSG — pre-render at build time
  { path: '',           renderMode: RenderMode.Prerender },
  { path: 'about',      renderMode: RenderMode.Prerender },
  { path: 'pricing',    renderMode: RenderMode.Prerender },

  // SSR — render on each request
  { path: 'dashboard',  renderMode: RenderMode.Server },
  { path: 'users/:id',  renderMode: RenderMode.Server },

  // Client-only — no server rendering
  { path: 'admin/**',   renderMode: RenderMode.Client },

  // Catch-all
  { path: '**',         renderMode: RenderMode.Server },
];

Hydration

Hydration is the process where Angular reuses the server-rendered DOM instead of destroying and re-creating it.

Server: Renders full HTML → sends to browser
Client: Angular bootstraps → finds existing DOM → attaches event listeners

Without hydration: visible flicker as Angular destroys server HTML and re-renders. With hydration: seamless — no flicker.

Incremental Hydration (Angular 19+)

<!-- Hydrate on viewport (lazy) -->
@defer (hydrate on viewport) {
  <app-product-list />
}

<!-- Hydrate on interaction -->
@defer (hydrate on interaction) {
  <app-comment-section />
}

<!-- Never hydrate (pure static content) -->
@defer (hydrate never) {
  <app-footer />
}

Platform Detection (Server vs Browser)

Some browser APIs (window, localStorage, document) don't exist on the server.

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

@Component({ ... })
export class MyComponent {
  private platformId = inject(PLATFORM_ID);

  ngOnInit() {
    if (isPlatformBrowser(this.platformId)) {
      // Safe to use browser APIs
      window.scrollTo(0, 0);
      localStorage.setItem('visited', 'true');
    }
  }
}

afterNextRender / afterRender (Angular 16+)

import { afterNextRender, afterRender } from '@angular/core';

@Component({ ... })
export class ChartComponent {
  constructor() {
    // Runs ONCE after first render — browser only
    afterNextRender(() => {
      this.initChart();   // Safe to access DOM
    });

    // Runs after EVERY render — browser only
    afterRender(() => {
      this.updateChartSize();
    });
  }
}

Transfer State (Avoid Double Fetch)

Without transfer state, data fetched on the server is fetched again on the client.

import { makeStateKey, TransferState } from '@angular/core';

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

@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);
  private transferState = inject(TransferState);
  private platformId = inject(PLATFORM_ID);

  getAll(): Observable<User[]> {
    // Check if data was already fetched on server
    const cached = this.transferState.get(USERS_KEY, null);
    if (cached) {
      this.transferState.remove(USERS_KEY);   // Use once
      return of(cached);
    }

    return this.http.get<User[]>('/api/users').pipe(
      tap(users => {
        if (isPlatformServer(this.platformId)) {
          this.transferState.set(USERS_KEY, users);   // Store for client
        }
      }),
    );
  }
}

> Angular 17+ with provideHttpClient(withFetch()) handles transfer state automatically for HTTP requests.


SEO — Meta Tags

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

@Component({ ... })
export class ProductDetailComponent {
  private meta = inject(Meta);
  private title = inject(Title);

  ngOnInit() {
    this.title.setTitle('Product Name - My Store');
    this.meta.updateTag({ name: 'description', content: 'Product description...' });
    this.meta.updateTag({ property: 'og:title', content: 'Product Name' });
    this.meta.updateTag({ property: 'og:image', content: 'https://...' });
    this.meta.updateTag({ name: 'robots', content: 'index, follow' });
  }
}

Dynamic Meta via Resolver

// route config
{
  path: 'products/:slug',
  loadComponent: () => import('./product-detail.component'),
  resolve: { product: productResolver },
  data: { title: 'Product Detail' },
}

// Global title strategy
@Injectable()
export class PageTitleStrategy extends TitleStrategy {
  private title = inject(Title);

  updateTitle(snapshot: RouterStateSnapshot) {
    const title = this.buildTitle(snapshot);
    this.title.setTitle(title ? `${title} — My App` : 'My App');
  }
}

// Provide
providers: [
  { provide: TitleStrategy, useClass: PageTitleStrategy },
]

Build & Deploy

# Build with SSR
ng build

# Run SSR server
node dist/my-app/server/server.mjs

# Deploy to:
# - Node.js server (Express)
# - Firebase Hosting + Cloud Functions
# - Vercel (auto-detects Angular SSR)
# - Netlify (with serverless functions)
# - Docker

Dockerfile

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

FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
EXPOSE 4000
CMD ["node", "dist/my-app/server/server.mjs"]

Key Takeaways

  • SSR renders pages on each request — best for dynamic, SEO-critical pages
  • SSG (Prerender) generates static HTML at build time — fastest for static pages
  • Hydration reuses server-rendered DOM — eliminates flash of content
  • Use afterNextRender() instead of isPlatformBrowser() for DOM access
  • provideHttpClient(withFetch()) auto-handles transfer state in Angular 17+
  • Use RenderMode in app.routes.server.ts to mix SSR, SSG, and client-only per route
  • Set meta tags with Title and Meta services for SEO