Skip to main content
Angular SSR

Module Overview

Estimated Time: 3-4 hours | Difficulty: Advanced | Prerequisites: Module 12
Server-Side Rendering (SSR) improves initial load performance and SEO by rendering your Angular app on the server. Angular provides built-in SSR support with hydration for seamless client-side takeover. What You’ll Learn:
  • SSR benefits and use cases
  • Setting up Angular Universal
  • Hydration and client takeover
  • SEO optimization
  • Handling browser APIs on server
  • Deployment strategies

SSR Benefits

┌─────────────────────────────────────────────────────────────────────────┐
│              Client-Side vs Server-Side Rendering                        │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   Client-Side Rendering (CSR)         Server-Side Rendering (SSR)       │
│   ───────────────────────────         ─────────────────────────         │
│                                                                          │
│   Browser ─────────────────▶          Browser ─────────────────▶        │
│   1. Request HTML (empty)             1. Request HTML                    │
│   2. Download JS bundles              2. Server renders HTML             │
│   3. Execute JavaScript               3. Send complete HTML              │
│   4. Render application               4. Display content (fast!)         │
│   5. Interactive                      5. Download JS in background       │
│   └──── Blank screen ────┘            6. Hydration                       │
│                                       7. Interactive                     │
│   Time to First Paint: SLOW           Time to First Paint: FAST          │
│                                                                          │
│   Benefits of SSR:                                                       │
│   ✓ Faster First Contentful Paint (FCP)                                │
│   ✓ Better SEO (crawlers see content)                                   │
│   ✓ Social media link previews work                                     │
│   ✓ Better Core Web Vitals                                              │
│   ✓ Works without JavaScript                                            │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Setting Up SSR

New Project with SSR

# Create new project with SSR
ng new my-app --ssr

# Or add to existing project
ng add @angular/ssr

Project Structure

my-app/
├── src/
│   ├── app/
│   │   └── app.config.ts        # Client config
│   ├── main.ts                  # Client entry point
│   └── main.server.ts           # Server entry point
├── server.ts                    # Express server
└── angular.json                 # Build configurations

Configuration Files

// app.config.ts (Client)
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser';
import { provideHttpClient, withFetch } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideClientHydration(),  // Enable hydration
    provideHttpClient(withFetch())
  ]
};
// app.config.server.ts (Server)
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);
// 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;

Express Server

// server.ts
import 'zone.js/node';
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr';
import express from 'express';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import bootstrap from './src/main.server';

export function app(): express.Express {
  const server = express();
  const serverDistFolder = dirname(fileURLToPath(import.meta.url));
  const browserDistFolder = resolve(serverDistFolder, '../browser');
  const indexHtml = join(serverDistFolder, 'index.server.html');

  const commonEngine = new CommonEngine();

  server.set('view engine', 'html');
  server.set('views', browserDistFolder);

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

  // Handle all other routes with Angular
  server.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));
  });

  return server;
}

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

run();

Hydration

Hydration connects the server-rendered DOM to Angular:
┌─────────────────────────────────────────────────────────────────────────┐
│                    Hydration Process                                     │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   1. Server Render                                                       │
│      ┌────────────────────────────────────────────┐                     │
│      │ <app-root>                                 │                     │
│      │   <div class="content">                    │                     │
│      │     <h1>Hello World</h1>   ← Real HTML    │                     │
│      │     <button>Click Me</button>              │                     │
│      │   </div>                                   │                     │
│      │ </app-root>                                │                     │
│      │ <script>window.__ngState__ = {...}</script>│ ← Transfer state   │
│      └────────────────────────────────────────────┘                     │
│                                                                          │
│   2. Browser Receives HTML (viewable immediately)                        │
│                                                                          │
│   3. JavaScript Downloads & Executes                                     │
│                                                                          │
│   4. Hydration                                                           │
│      • Angular attaches to existing DOM (no re-render!)                 │
│      • Event listeners added                                            │
│      • Components become interactive                                     │
│      • State transferred from server                                     │
│                                                                          │
│   Benefits:                                                              │
│   ✓ No flickering (DOM reuse)                                          │
│   ✓ Faster Time to Interactive                                          │
│   ✓ State preserved from server                                         │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Hydration Configuration

// app.config.ts
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(
      withEventReplay()  // Replay events that occur before hydration
    )
  ]
};

Handling Browser APIs

Browser APIs (window, document, localStorage) don’t exist on the server:
import { Component, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';

@Component({...})
export class HeaderComponent {
  private platformId = inject(PLATFORM_ID);
  
  ngOnInit() {
    if (isPlatformBrowser(this.platformId)) {
      // Only runs in browser
      window.addEventListener('scroll', this.onScroll);
      const theme = localStorage.getItem('theme');
    }
    
    if (isPlatformServer(this.platformId)) {
      // Only runs on server
      console.log('Rendering on server');
    }
  }
}

afterNextRender and afterRender

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

@Component({
  selector: 'app-chart',
  template: `<div #chartContainer></div>`
})
export class ChartComponent {
  @ViewChild('chartContainer') container!: ElementRef;
  
  constructor() {
    // Runs ONCE after first render in browser
    afterNextRender(() => {
      // Safe to use browser APIs here
      this.initChart();
    });
    
    // Runs EVERY render in browser
    afterRender(() => {
      // Update chart on each render
      this.updateChart();
    });
  }
  
  private initChart() {
    // Chart.js, D3, etc.
    new Chart(this.container.nativeElement, {...});
  }
}

Transfer State

Share data between server and client to avoid duplicate requests:
// data.service.ts
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TransferState, makeStateKey } from '@angular/platform-browser';
import { isPlatformServer } from '@angular/common';
import { of, tap } from 'rxjs';

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

@Injectable({ providedIn: 'root' })
export class DataService {
  private http = inject(HttpClient);
  private transferState = inject(TransferState);
  private platformId = inject(PLATFORM_ID);
  
  getUsers(): Observable<User[]> {
    // Check if data was transferred from server
    if (this.transferState.hasKey(USERS_KEY)) {
      const users = this.transferState.get(USERS_KEY, []);
      this.transferState.remove(USERS_KEY);  // Clean up
      return of(users);
    }
    
    // Fetch from API
    return this.http.get<User[]>('/api/users').pipe(
      tap(users => {
        // Save to transfer state on server
        if (isPlatformServer(this.platformId)) {
          this.transferState.set(USERS_KEY, users);
        }
      })
    );
  }
}

Automatic HttpClient Transfer

Angular can automatically handle HTTP transfer state:
// app.config.ts
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(withFetch()),
    provideClientHydration(
      withHttpTransferCacheOptions({
        includePostRequests: true  // Cache POST requests too
      })
    )
  ]
};

SEO Optimization

Meta Tags and Title

// seo.service.ts
import { Injectable, inject } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';

@Injectable({ providedIn: 'root' })
export class SeoService {
  private meta = inject(Meta);
  private title = inject(Title);
  
  updatePageMeta(config: {
    title: string;
    description: string;
    image?: string;
    url?: string;
  }) {
    // Title
    this.title.setTitle(config.title);
    
    // Standard meta tags
    this.meta.updateTag({ name: 'description', content: config.description });
    
    // Open Graph (Facebook, LinkedIn)
    this.meta.updateTag({ property: 'og:title', content: config.title });
    this.meta.updateTag({ property: 'og:description', content: config.description });
    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
    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 });
    }
  }
}
// product-detail.component.ts
@Component({...})
export class ProductDetailComponent {
  private seoService = inject(SeoService);
  private route = inject(ActivatedRoute);
  
  product = toSignal(
    this.route.data.pipe(map(data => data['product']))
  );
  
  constructor() {
    effect(() => {
      const product = this.product();
      if (product) {
        this.seoService.updatePageMeta({
          title: `${product.name} | My Store`,
          description: product.description.slice(0, 160),
          image: product.image,
          url: `https://mystore.com/products/${product.id}`
        });
      }
    });
  }
}

Structured Data (JSON-LD)

// structured-data.service.ts
import { Injectable, inject, Renderer2, DOCUMENT } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class StructuredDataService {
  private document = inject(DOCUMENT);
  
  setProductSchema(product: Product) {
    const schema = {
      '@context': 'https://schema.org',
      '@type': 'Product',
      name: product.name,
      description: product.description,
      image: product.image,
      offers: {
        '@type': 'Offer',
        price: product.price,
        priceCurrency: 'USD',
        availability: product.inStock 
          ? 'https://schema.org/InStock' 
          : 'https://schema.org/OutOfStock'
      }
    };
    
    this.setJsonLd(schema);
  }
  
  setArticleSchema(article: Article) {
    const schema = {
      '@context': 'https://schema.org',
      '@type': 'Article',
      headline: article.title,
      author: {
        '@type': 'Person',
        name: article.author
      },
      datePublished: article.publishDate,
      image: article.image
    };
    
    this.setJsonLd(schema);
  }
  
  private setJsonLd(schema: object) {
    // Remove existing
    const existing = this.document.querySelector('script[type="application/ld+json"]');
    if (existing) {
      existing.remove();
    }
    
    // Add new
    const script = this.document.createElement('script');
    script.type = 'application/ld+json';
    script.text = JSON.stringify(schema);
    this.document.head.appendChild(script);
  }
}

Prerendering (SSG)

Generate static HTML at build time:
// angular.json
{
  "projects": {
    "my-app": {
      "architect": {
        "build": {
          "options": {
            "prerender": {
              "routesFile": "routes.txt",
              // Or discover routes automatically
              "discoverRoutes": true
            }
          }
        }
      }
    }
  }
}

// routes.txt
/
/about
/products/1
/products/2
/blog/post-1
/blog/post-2
# Build with prerendering
ng build

# Output
dist/
├── browser/
   ├── index.html           # Prerendered home page
   ├── about/index.html     # Prerendered about page
   ├── products/
   ├── 1/index.html
   └── 2/index.html
   └── ...
└── server/
    └── ...

Deployment

Node.js Server

# Dockerfile
FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY dist ./dist

EXPOSE 4000
CMD ["node", "dist/server/server.mjs"]
# docker-compose.yml
version: '3.8'
services:
  angular-ssr:
    build: .
    ports:
      - "4000:4000"
    environment:
      - NODE_ENV=production

Serverless (AWS Lambda, Cloud Functions)

// serverless handler
import { Handler } from 'aws-lambda';
import { app } from './server';

const server = app();

export const handler: Handler = async (event, context) => {
  // Use serverless-http or similar adapter
  return await serverlessExpress({ app: server })(event, context);
};

Static Hosting (with prerendering)

# Build static files
ng build

# Deploy browser folder to:
# - Netlify
# - Vercel
# - AWS S3 + CloudFront
# - Firebase Hosting

Practice Exercise

Exercise: Add SSR to a Blog

Add SSR to a blog application:
  1. Configure Angular Universal
  2. Add SEO meta tags for posts
  3. Implement transfer state for API data
  4. Add structured data for articles
  5. Handle client-only components
// blog-post.component.ts
@Component({
  selector: 'app-blog-post',
  standalone: true,
  template: `
    @if (post(); as post) {
      <article>
        <h1>{{ post.title }}</h1>
        <div class="meta">
          <span>{{ post.author }}</span>
          <time>{{ post.date | date }}</time>
        </div>
        <div [innerHTML]="post.content"></div>
        
        <!-- Client-only: Comments with real-time updates -->
        @defer (on idle) {
          <app-comments [postId]="post.id" />
        } @placeholder {
          <div class="comments-placeholder">Loading comments...</div>
        }
      </article>
    }
  `
})
export class BlogPostComponent {
  private route = inject(ActivatedRoute);
  private seoService = inject(SeoService);
  private structuredData = inject(StructuredDataService);
  private blogService = inject(BlogService);
  
  post = toSignal(
    this.route.params.pipe(
      switchMap(params => this.blogService.getPost(params['slug']))
    )
  );
  
  constructor() {
    effect(() => {
      const post = this.post();
      if (post) {
        // SEO Meta Tags
        this.seoService.updatePageMeta({
          title: `${post.title} | My Blog`,
          description: post.excerpt,
          image: post.featuredImage,
          url: `https://myblog.com/posts/${post.slug}`
        });
        
        // Structured Data
        this.structuredData.setArticleSchema({
          title: post.title,
          author: post.author,
          publishDate: post.date,
          image: post.featuredImage
        });
      }
    });
  }
}

// blog.service.ts
@Injectable({ providedIn: 'root' })
export class BlogService {
  private http = inject(HttpClient);
  private transferState = inject(TransferState);
  private platformId = inject(PLATFORM_ID);
  
  getPost(slug: string): Observable<BlogPost> {
    const key = makeStateKey<BlogPost>(`post-${slug}`);
    
    if (this.transferState.hasKey(key)) {
      const post = this.transferState.get(key, null as any);
      this.transferState.remove(key);
      return of(post);
    }
    
    return this.http.get<BlogPost>(`/api/posts/${slug}`).pipe(
      tap(post => {
        if (isPlatformServer(this.platformId)) {
          this.transferState.set(key, post);
        }
      })
    );
  }
}

// comments.component.ts (client-only)
@Component({
  selector: 'app-comments',
  standalone: true,
  template: `
    <section class="comments">
      @for (comment of comments(); track comment.id) {
        <div class="comment">
          <strong>{{ comment.author }}</strong>
          <p>{{ comment.text }}</p>
        </div>
      }
      
      <form (submit)="addComment($event)">
        <textarea [(ngModel)]="newComment"></textarea>
        <button type="submit">Post Comment</button>
      </form>
    </section>
  `
})
export class CommentsComponent {
  postId = input.required<number>();
  
  private commentService = inject(CommentService);
  comments = signal<Comment[]>([]);
  newComment = '';
  
  constructor() {
    // This only runs in browser (component is deferred)
    afterNextRender(() => {
      this.loadComments();
      this.commentService.subscribeToUpdates(this.postId());
    });
  }
  
  loadComments() {
    this.commentService.getComments(this.postId())
      .subscribe(comments => this.comments.set(comments));
  }
  
  addComment(event: Event) {
    event.preventDefault();
    this.commentService.addComment(this.postId(), this.newComment)
      .subscribe(() => {
        this.newComment = '';
        this.loadComments();
      });
  }
}

Summary

1

SSR Setup

Use @angular/ssr for built-in server-side rendering
2

Hydration

Enable provideClientHydration() for seamless client takeover
3

Platform Checks

Use isPlatformBrowser/isPlatformServer for platform-specific code
4

Transfer State

Share data between server and client to avoid duplicate requests
5

SEO

Use Meta, Title services and structured data for search engines

Next Steps

Next: Security Best Practices

Learn security patterns to protect your Angular application