Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

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. Why does this matter? Without SSR, your Angular app ships an empty <div> to the browser, which then downloads JavaScript, executes it, and finally renders the page. During that loading time (often 2-5 seconds on slow connections), search engine crawlers see nothing — which devastates your SEO rankings. Social media previews (sharing a link on LinkedIn or Twitter) show a blank card. And users on slower devices stare at a white screen. SSR solves all three problems by sending a fully rendered HTML page immediately. 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

When NOT to use SSR: If your app is a fully authenticated dashboard (like an admin panel) where SEO does not matter and users are always logged in, SSR adds complexity without much benefit. The sweet spot for SSR is public-facing content: marketing pages, blog posts, e-commerce product pages, and any page you want to rank in Google or preview on social media.

New Project with SSR

# Create new project with SSR built in from the start
ng new my-app --ssr

# Or add SSR to an existing project -- this generates server.ts,
# main.server.ts, and updates angular.json automatically
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) do not exist on the server. This is the single most common source of SSR bugs — your app works perfectly in the browser, but crashes on the server because code tries to access window.innerWidth or localStorage.getItem() during server-side rendering. The fix is straightforward: guard all browser-specific code behind platform checks.
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

These lifecycle hooks are the preferred way to run browser-only code in Angular 17+. Unlike isPlatformBrowser checks, they are guaranteed to only execute in the browser — there is no risk of accidentally running browser code on the server. Use afterNextRender for one-time initialization (chart libraries, analytics scripts) and afterRender for code that must run after every change detection cycle (DOM measurements, scroll synchronization).
Third-party library pitfall: Libraries like Chart.js, D3, or Google Maps that access window or document at import time will crash the server even inside an afterNextRender block, because the import statement at the top of the file executes during module loading. The fix: use dynamic import() inside afterNextRender so the library code only loads in the browser.
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 async initChart() {
    // Dynamic import ensures Chart.js is ONLY downloaded in the browser.
    // If you used a static import at the top of the file, the server would
    // try to load Chart.js during SSR and crash on "window is not defined".
    const { Chart } = await import('chart.js');
    new Chart(this.container.nativeElement, {...});
  }
}

Transfer State

Share data between server and client to avoid duplicate requests. Without transfer state, here is what happens: the server fetches data from your API, renders the HTML, and sends it to the browser. Then the browser bootstraps Angular, which runs ngOnInit again and fetches the same data from the same API a second time — wasting bandwidth and causing a visible content flicker. Transfer state solves this by embedding the server-fetched data directly in the HTML payload, so the client can reuse it instead of re-fetching.
// 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. Prerendering (also called Static Site Generation or SSG) is the best of both worlds for content that does not change on every request — you get the SEO benefits of SSR and the deployment simplicity of static files. The HTML is generated once during ng build, not on every request, so there is zero server load at runtime. Perfect for blog posts, documentation, marketing pages, and product pages that update infrequently.
SSR vs SSG decision: If the page content is the same for every visitor (a blog post, a product page), use prerendering — it is faster and cheaper. If the page content depends on the current user (a personalized dashboard, a user profile), you need runtime SSR because the server must render different HTML for each request.
// 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

Interview Deep-Dive

Strong Answer: I evaluate SSR on three criteria. First, SEO — if the app is a marketing site, e-commerce catalog, or blog where search ranking matters, SSR is almost mandatory. Second, First Contentful Paint on slow networks — SSR cuts perceived load time from 5+ seconds to under 1 second for users on 3G. Third, social media previews — without SSR, sharing a link on LinkedIn shows a blank card.If the app is an internal dashboard behind authentication, SSR provides zero SEO benefit. The added complexity (server infrastructure, platform checks, hydration bugs) is not justified.What commonly breaks: any code that accesses window, document, localStorage, or navigator at import time or during rendering. Third-party charting libraries that assume a browser. WebSocket connections attempted during server render. The fix: wrap all browser API access with isPlatformBrowser checks and move DOM-dependent code into afterNextRender callbacks.Follow-up: How does hydration prevent the “flash” that older SSR approaches had? Answer: With provideClientHydration(), Angular reuses the server-rendered DOM nodes instead of tearing them down and re-rendering. It attaches event listeners to existing elements without recreating them. Event replay (withEventReplay) captures user clicks that happen before hydration completes and replays them afterward, so no interaction is lost.
Strong Answer: Without transfer state, the server fetches data, renders HTML, and sends it. The browser then bootstraps Angular and fetches the same data again because the client component does not know it was already fetched. The user sees a flash where server-rendered content disappears during re-fetching.Transfer state embeds the server’s API responses as serialized JSON in the HTML. The client HttpClient checks this cache before making network calls. The automatic approach (withHttpTransferCacheOptions in provideClientHydration) handles this transparently for all GET requests. The includePostRequests option extends it to POST requests.The manual approach (TransferState service with makeStateKey) is needed for data fetched outside HttpClient — from third-party SDKs, WebSockets, or GraphQL clients.Follow-up: When would the automatic transfer cache cause problems? Answer: When the API response depends on context that differs between server and client — for example, geolocation-based content where the server IP resolves to a different region. Also, large payloads: if the server fetches 10MB of data, that gets embedded in the HTML, making the initial download much larger.
Strong Answer: SSR renders every page on every request — best for frequently changing or user-specific content. SSG renders at build time into static HTML files — best for static content like docs or marketing pages, deployable to any CDN. ISR is a hybrid where pages are pre-rendered but regenerated in the background after a configurable interval.My decision framework: content changes less than daily, use SSG. Changes frequently but is not user-specific, use ISR or SSR with caching. User-specific or real-time, use full SSR. For e-commerce: product listing pages get SSG/ISR, product detail pages get SSR (price must be current), cart is client-side only.Follow-up: Can you mix these strategies in one Angular app? Answer: Yes. In angular.json, the prerender config specifies which routes to prerender as static HTML. Remaining routes fall through to the SSR server. Client-only routes behind auth guards skip both. You can prerender /about, SSR /products/*, and client-render /dashboard in the same deployment.

Next Steps

Next: Security Best Practices

Learn security patterns to protect your Angular application