Back to skills
SkillHub ClubShip Full StackFull StackBackend

angular-ssr

Implement server-side rendering and hydration in Angular v20+ using @angular/ssr. Use for SSR setup, hydration strategies, prerendering static pages, and handling browser-only APIs. Triggers on SSR configuration, fixing hydration mismatches, prerendering routes, or making code SSR-compatible.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
544
Hot score
99
Updated
March 20, 2026
Overall rating
C3.6
Composite score
3.6
Best-practice grade
B70.0

Install command

npx @skill-hub/cli install analogjs-angular-skills-angular-ssr

Repository

analogjs/angular-skills

Skill path: skills/angular-ssr

Implement server-side rendering and hydration in Angular v20+ using @angular/ssr. Use for SSR setup, hydration strategies, prerendering static pages, and handling browser-only APIs. Triggers on SSR configuration, fixing hydration mismatches, prerendering routes, or making code SSR-compatible.

Open repository

Best for

Primary workflow: Ship Full Stack.

Technical facets: Full Stack, Backend.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: analogjs.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install angular-ssr into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/analogjs/angular-skills before adding angular-ssr to shared team environments
  • Use angular-ssr for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: angular-ssr
description: Implement server-side rendering and hydration in Angular v20+ using @angular/ssr. Use for SSR setup, hydration strategies, prerendering static pages, and handling browser-only APIs. Triggers on SSR configuration, fixing hydration mismatches, prerendering routes, or making code SSR-compatible.
---

# Angular SSR

Implement server-side rendering, hydration, and prerendering in Angular v20+.

## Setup

### Add SSR to Existing Project

```bash
ng add @angular/ssr
```

This adds:
- `@angular/ssr` package
- `server.ts` - Express server
- `src/main.server.ts` - Server bootstrap
- `src/app/app.config.server.ts` - Server providers
- Updates `angular.json` with SSR configuration

### Project Structure

```
src/
├── app/
│   ├── app.config.ts          # Browser config
│   ├── app.config.server.ts   # Server config
│   └── app.routes.ts
├── main.ts                     # Browser bootstrap
├── main.server.ts              # Server bootstrap
server.ts                       # Express server
```

## Configuration

### app.config.server.ts

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

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

export const config = mergeApplicationConfig(appConfig, serverConfig);
```

### Server Routes Configuration

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

export const serverRoutes: ServerRoute[] = [
  {
    path: '',
    renderMode: RenderMode.Prerender, // Static at build time
  },
  {
    path: 'products',
    renderMode: RenderMode.Prerender,
  },
  {
    path: 'products/:id',
    renderMode: RenderMode.Server, // Dynamic SSR
  },
  {
    path: 'dashboard',
    renderMode: RenderMode.Client, // Client-only (SPA)
  },
  {
    path: '**',
    renderMode: RenderMode.Server,
  },
];
```

### Render Modes

| Mode | Description | Use Case |
|------|-------------|----------|
| `RenderMode.Prerender` | Static HTML at build time | Marketing pages, blogs |
| `RenderMode.Server` | Dynamic SSR per request | User-specific content |
| `RenderMode.Client` | Client-side only (SPA) | Authenticated dashboards |

## Hydration

### Default Hydration

Hydration is enabled by default with `provideClientHydration()`:

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

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(),
    // ...
  ],
};
```

### Incremental Hydration

Defer hydration of specific components:

```typescript
@Component({
  template: `
    <!-- Hydrate when visible -->
    @defer (hydrate on viewport) {
      <app-comments [postId]="postId" />
    } @placeholder {
      <div class="comments-placeholder">Loading comments...</div>
    }
    
    <!-- Hydrate on interaction -->
    @defer (hydrate on interaction) {
      <app-interactive-chart [data]="chartData" />
    }
    
    <!-- Hydrate on idle -->
    @defer (hydrate on idle) {
      <app-recommendations />
    }
    
    <!-- Never hydrate (static only) -->
    @defer (hydrate never) {
      <app-static-footer />
    }
  `,
})
export class Post {
  postId = input.required<string>();
  chartData = input.required<ChartData>();
}
```

### Hydration Triggers

| Trigger | Description |
|---------|-------------|
| `hydrate on viewport` | When element enters viewport |
| `hydrate on interaction` | On click, focus, or input |
| `hydrate on idle` | When browser is idle |
| `hydrate on immediate` | Immediately after load |
| `hydrate on timer(ms)` | After specified delay |
| `hydrate when condition` | When expression is true |
| `hydrate never` | Never hydrate (static) |

### Event Replay

Capture user events before hydration completes:

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

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(withEventReplay()),
  ],
};
```

## Browser-Only Code

### Platform Detection

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

@Component({...})
export class My {
  private platformId = inject(PLATFORM_ID);
  
  ngOnInit() {
    if (isPlatformBrowser(this.platformId)) {
      // Browser-only code
      window.addEventListener('scroll', this.onScroll);
    }
  }
}
```

### afterNextRender / afterRender

Run code only in browser after rendering:

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

@Component({...})
export class Chart {
  constructor() {
    // Runs once after first render (browser only)
    afterNextRender(() => {
      this.initChart();
    });
    
    // Runs after every render (browser only)
    afterRender(() => {
      this.updateChart();
    });
  }
  
  private initChart() {
    // Safe to use DOM APIs here
    const canvas = document.getElementById('chart');
    new Chart(canvas, this.config);
  }
}
```

### Inject Browser APIs Safely

```typescript
// tokens.ts
import { InjectionToken, PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

export const WINDOW = new InjectionToken<Window | null>('Window', {
  providedIn: 'root',
  factory: () => {
    const platformId = inject(PLATFORM_ID);
    return isPlatformBrowser(platformId) ? window : null;
  },
});

export const LOCAL_STORAGE = new InjectionToken<Storage | null>('LocalStorage', {
  providedIn: 'root',
  factory: () => {
    const platformId = inject(PLATFORM_ID);
    return isPlatformBrowser(platformId) ? localStorage : null;
  },
});

// Usage
@Injectable({ providedIn: 'root' })
export class Storage {
  private storage = inject(LOCAL_STORAGE);
  
  get(key: string): string | null {
    return this.storage?.getItem(key) ?? null;
  }
  
  set(key: string, value: string): void {
    this.storage?.setItem(key, value);
  }
}
```

## Prerendering

### Static Routes

```typescript
// app.routes.server.ts
export const serverRoutes: ServerRoute[] = [
  { path: '', renderMode: RenderMode.Prerender },
  { path: 'about', renderMode: RenderMode.Prerender },
  { path: 'contact', renderMode: RenderMode.Prerender },
  { path: 'blog', renderMode: RenderMode.Prerender },
];
```

### Dynamic Routes with getPrerenderParams

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

export const serverRoutes: ServerRoute[] = [
  {
    path: 'products/:id',
    renderMode: RenderMode.Prerender,
    async getPrerenderParams() {
      // Fetch product IDs to prerender
      const response = await fetch('https://api.example.com/products');
      const products = await response.json();
      return products.map((p: Product) => ({ id: p.id }));
    },
    fallback: PrerenderFallback.Server, // SSR for non-prerendered
  },
  {
    path: 'blog/:slug',
    renderMode: RenderMode.Prerender,
    async getPrerenderParams() {
      const posts = await fetchBlogPosts();
      return posts.map(post => ({ slug: post.slug }));
    },
    fallback: PrerenderFallback.Client, // SPA for non-prerendered
  },
];
```

### Prerender Fallback Options

| Fallback | Description |
|----------|-------------|
| `PrerenderFallback.Server` | SSR for non-prerendered routes |
| `PrerenderFallback.Client` | Client-side rendering |
| `PrerenderFallback.None` | 404 for non-prerendered routes |

## HTTP Caching

### TransferState

Automatically transfer HTTP responses from server to client:

```typescript
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(
      withHttpTransferCacheOptions({
        includePostRequests: true,
        includeRequestsWithAuthHeaders: false,
        filter: (req) => !req.url.includes('/api/realtime'),
      })
    ),
  ],
};
```

### Manual TransferState

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

const PRODUCTS_KEY = makeStateKey<Product[]>('products');

@Injectable({ providedIn: 'root' })
export class Product {
  private http = inject(HttpClient);
  private transferState = inject(TransferState);
  private platformId = inject(PLATFORM_ID);
  
  getProducts(): Observable<Product[]> {
    // Check if data was transferred from server
    if (this.transferState.hasKey(PRODUCTS_KEY)) {
      const products = this.transferState.get(PRODUCTS_KEY, []);
      this.transferState.remove(PRODUCTS_KEY);
      return of(products);
    }
    
    return this.http.get<Product[]>('/api/products').pipe(
      tap(products => {
        // Store for transfer on server
        if (isPlatformServer(this.platformId)) {
          this.transferState.set(PRODUCTS_KEY, products);
        }
      })
    );
  }
}
```

## Build and Deploy

### Build Commands

```bash
# Build with SSR
ng build

# Output structure
dist/
├── my-app/
│   ├── browser/      # Client assets
│   └── server/       # Server bundle
```

### Run SSR Server

```bash
# Development
npm run serve:ssr:my-app

# Production
node dist/my-app/server/server.mjs
```

### Deploy to Node.js Host

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

const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const indexHtml = join(serverDistFolder, 'index.server.html');

const app = express();
const commonEngine = new CommonEngine();

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

app.get('*', (req, res, next) => {
  commonEngine
    .render({
      bootstrap,
      documentFilePath: indexHtml,
      url: req.originalUrl,
      publicPath: browserDistFolder,
      providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }],
    })
    .then((html) => res.send(html))
    .catch((err) => next(err));
});

app.listen(4000, () => {
  console.log('Server listening on http://localhost:4000');
});
```

For advanced patterns, see [references/ssr-patterns.md](references/ssr-patterns.md).


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### references/ssr-patterns.md

```markdown
# Angular SSR Patterns

## Table of Contents
- [Hydration Debugging](#hydration-debugging)
- [SEO Optimization](#seo-optimization)
- [Authentication with SSR](#authentication-with-ssr)
- [Caching Strategies](#caching-strategies)
- [Error Handling](#error-handling)
- [Performance Optimization](#performance-optimization)

## Hydration Debugging

### Common Hydration Mismatches

```typescript
// Problem: Different content on server vs client
@Component({
  template: `<p>Current time: {{ currentTime }}</p>`,
})
export class Time {
  // BAD: Different value on server and client
  currentTime = new Date().toLocaleTimeString();
}

// Solution: Use afterNextRender or skip SSR
@Component({
  template: `<p>Current time: {{ currentTime() }}</p>`,
})
export class Time {
  currentTime = signal('');
  
  constructor() {
    afterNextRender(() => {
      this.currentTime.set(new Date().toLocaleTimeString());
    });
  }
}
```

### Skip Hydration for Dynamic Content

```typescript
@Component({
  template: `
    <!-- Skip hydration for this subtree -->
    <div ngSkipHydration>
      <app-dynamic-widget />
    </div>
  `,
})
export class Page {}
```

### Debug Hydration Issues

```typescript
// Enable hydration debugging in development
import { provideClientHydration, withNoDomReuse } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideClientHydration(
      // Disable DOM reuse to see hydration errors clearly
      ...(isDevMode() ? [withNoDomReuse()] : [])
    ),
  ],
};
```

## SEO Optimization

### Meta Tags Service

```typescript
import { Injectable, inject } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
import { DOCUMENT } from '@angular/common';

@Injectable({ providedIn: 'root' })
export class Seo {
  private meta = inject(Meta);
  private title = inject(Title);
  private document = inject(DOCUMENT);
  
  updateMetaTags(config: {
    title: string;
    description: string;
    image?: string;
    url?: string;
    type?: string;
  }) {
    // Basic meta
    this.title.setTitle(config.title);
    this.meta.updateTag({ name: 'description', content: config.description });
    
    // Open Graph
    this.meta.updateTag({ property: 'og:title', content: config.title });
    this.meta.updateTag({ property: 'og:description', content: config.description });
    this.meta.updateTag({ property: 'og:type', content: config.type || 'website' });
    
    if (config.image) {
      this.meta.updateTag({ property: 'og:image', content: config.image });
    }
    
    if (config.url) {
      this.meta.updateTag({ property: 'og:url', content: config.url });
      this.updateCanonicalUrl(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 });
    }
  }
  
  private updateCanonicalUrl(url: string) {
    let link: HTMLLinkElement | null = this.document.querySelector('link[rel="canonical"]');
    
    if (!link) {
      link = this.document.createElement('link');
      link.setAttribute('rel', 'canonical');
      this.document.head.appendChild(link);
    }
    
    link.setAttribute('href', url);
  }
  
  setJsonLd(data: object) {
    let script: HTMLScriptElement | null = this.document.querySelector('script[type="application/ld+json"]');
    
    if (!script) {
      script = this.document.createElement('script');
      script.type = 'application/ld+json';
      this.document.head.appendChild(script);
    }
    
    script.textContent = JSON.stringify(data);
  }
}

// Usage in component
@Component({...})
export class Product {
  private seo = inject(Seo);
  product = input.required<Product>();
  
  constructor() {
    effect(() => {
      const product = this.product();
      this.seo.updateMetaTags({
        title: `${product.name} | My Store`,
        description: product.description,
        image: product.imageUrl,
        url: `https://mystore.com/products/${product.id}`,
        type: 'product',
      });
      
      this.seo.setJsonLd({
        '@context': 'https://schema.org',
        '@type': 'Product',
        name: product.name,
        description: product.description,
        image: product.imageUrl,
        offers: {
          '@type': 'Offer',
          price: product.price,
          priceCurrency: 'USD',
        },
      });
    });
  }
}
```

### Route-Based SEO with Resolvers

```typescript
// seo.resolver.ts
export const seoResolver: ResolveFn<SeoData> = async (route) => {
  const productId = route.paramMap.get('id')!;
  const productService = inject(Product);
  const product = await productService.getById(productId);
  
  return {
    title: `${product.name} | My Store`,
    description: product.description,
    image: product.imageUrl,
  };
};

// Routes
{
  path: 'products/:id',
  component: Product,
  resolve: { seo: seoResolver },
}

// Component
@Component({...})
export class Product {
  private seo = inject(Seo);
  seoData = input.required<SeoData>(); // From resolver
  
  constructor() {
    effect(() => {
      this.seo.updateMetaTags(this.seoData());
    });
  }
}
```

## Authentication with SSR

### Cookie-Based Auth

```typescript
// Server-side cookie reading
import { REQUEST } from '@angular/ssr/tokens';

@Injectable({ providedIn: 'root' })
export class Auth {
  private request = inject(REQUEST, { optional: true });
  private platformId = inject(PLATFORM_ID);
  
  getToken(): string | null {
    if (isPlatformServer(this.platformId) && this.request) {
      // Read from request cookies on server
      const cookies = this.request.headers.cookie || '';
      const match = cookies.match(/auth_token=([^;]+)/);
      return match ? match[1] : null;
    }
    
    if (isPlatformBrowser(this.platformId)) {
      // Read from document cookies on client
      const match = document.cookie.match(/auth_token=([^;]+)/);
      return match ? match[1] : null;
    }
    
    return null;
  }
}
```

### Skip SSR for Authenticated Routes

```typescript
// app.routes.server.ts
export const serverRoutes: ServerRoute[] = [
  // Public routes - prerender
  { path: '', renderMode: RenderMode.Prerender },
  { path: 'products', renderMode: RenderMode.Prerender },
  
  // Authenticated routes - client only
  { path: 'dashboard', renderMode: RenderMode.Client },
  { path: 'profile', renderMode: RenderMode.Client },
  { path: 'settings', renderMode: RenderMode.Client },
];
```

## Caching Strategies

### HTTP Cache Headers

```typescript
// server.ts
import { REQUEST, RESPONSE_INIT } from '@angular/ssr/tokens';

// In route configuration or component
@Component({...})
export class ProductList {
  private responseInit = inject(RESPONSE_INIT, { optional: true });
  
  constructor() {
    // Set cache headers for SSR response
    if (this.responseInit) {
      this.responseInit.headers = {
        ...this.responseInit.headers,
        'Cache-Control': 'public, max-age=3600, s-maxage=86400',
      };
    }
  }
}
```

### CDN Caching with Vary Headers

```typescript
// server.ts - Express middleware
app.use((req, res, next) => {
  // Vary by cookie for authenticated content
  res.setHeader('Vary', 'Cookie');
  next();
});
```

### Stale-While-Revalidate

```typescript
// Set SWR headers for dynamic content
this.responseInit.headers = {
  'Cache-Control': 'public, max-age=60, stale-while-revalidate=3600',
};
```

## Error Handling

### SSR Error Boundaries

```typescript
// error-handler.ts
import { ErrorHandler, Injectable, inject } from '@angular/core';
import { PLATFORM_ID } from '@angular/core';
import { isPlatformServer } from '@angular/common';

@Injectable()
export class SsrError implements ErrorHandler {
  private platformId = inject(PLATFORM_ID);
  
  handleError(error: Error) {
    if (isPlatformServer(this.platformId)) {
      // Log server errors
      console.error('SSR Error:', error);
      // Could send to monitoring service
    } else {
      // Client-side error handling
      console.error('Client Error:', error);
    }
  }
}

// Provide in app.config.ts
{ provide: ErrorHandler, useClass: SsrError }
```

### Graceful Degradation

```typescript
@Component({
  template: `
    @if (dataError()) {
      <!-- Fallback content that works without data -->
      <app-fallback-content />
    } @else {
      <app-data-content [data]="data()" />
    }
  `,
})
export class PageCmpt {
  private dataService = inject(Data);
  
  data = signal<Data | null>(null);
  dataError = signal(false);
  
  constructor() {
    this.loadData();
  }
  
  private async loadData() {
    try {
      const data = await this.dataService.getData();
      this.data.set(data);
    } catch {
      this.dataError.set(true);
    }
  }
}
```

## Performance Optimization

### Lazy Hydration Strategy

```typescript
@Component({
  template: `
    <!-- Critical content - hydrate immediately -->
    <header>
      <app-navigation />
    </header>
    
    <!-- Main content - hydrate on viewport -->
    <main>
      @defer (hydrate on viewport) {
        <app-product-grid [products]="products()" />
      }
    </main>
    
    <!-- Below fold - hydrate on idle -->
    @defer (hydrate on idle) {
      <app-reviews [productId]="productId()" />
    }
    
    <!-- Interactive only - hydrate on interaction -->
    @defer (hydrate on interaction) {
      <app-chat-widget />
    }
    
    <!-- Static footer - never hydrate -->
    @defer (hydrate never) {
      <app-footer />
    }
  `,
})
export class ProductPage {}
```

### Preload Critical Data

```typescript
// app.routes.server.ts
export const serverRoutes: ServerRoute[] = [
  {
    path: 'products/:id',
    renderMode: RenderMode.Server,
    async getPrerenderParams() {
      // Prerender top 100 products
      const topProducts = await fetchTopProducts(100);
      return topProducts.map(p => ({ id: p.id }));
    },
  },
];
```

### Streaming SSR (Experimental)

```typescript
// Enable streaming for faster TTFB
import { provideServerRendering } from '@angular/platform-server';

const serverConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(),
    // Streaming is automatic with @defer blocks
  ],
};
```

## Testing SSR

### Test Server Rendering

```typescript
import { renderApplication } from '@angular/platform-server';
import { App } from './app.component';
import { config } from './app.config.server';

describe('SSR', () => {
  it('should render home page', async () => {
    const html = await renderApplication(App, {
      appId: 'my-app',
      providers: config.providers,
      url: '/',
    });
    
    expect(html).toContain('<h1>Welcome</h1>');
    expect(html).toContain('</app-root>');
  });
  
  it('should render product page with data', async () => {
    const html = await renderApplication(App, {
      appId: 'my-app',
      providers: config.providers,
      url: '/products/123',
    });
    
    expect(html).toContain('Product Name');
    expect(html).not.toContain('Loading...');
  });
});
```

### Test Hydration

```typescript
import { TestBed } from '@angular/core/testing';
import { provideClientHydration } from '@angular/platform-browser';

describe('Hydration', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideClientHydration()],
    });
  });
  
  it('should hydrate without errors', () => {
    const fixture = TestBed.createComponent(App);
    fixture.detectChanges();
    
    // No hydration mismatch errors should be thrown
    expect(fixture.componentInstance).toBeTruthy();
  });
});
```

```

angular-ssr | SkillHub