--- 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: ` @defer (hydrate on viewport) { } @placeholder {
Loading comments...
} @defer (hydrate on interaction) { } @defer (hydrate on idle) { } @defer (hydrate never) { } `, }) export class PostComponent { postId = input.required(); chartData = input.required(); } ``` ### 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 MyComponent { 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 ChartComponent { 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', { providedIn: 'root', factory: () => { const platformId = inject(PLATFORM_ID); return isPlatformBrowser(platformId) ? window : null; }, }); export const LOCAL_STORAGE = new InjectionToken('LocalStorage', { providedIn: 'root', factory: () => { const platformId = inject(PLATFORM_ID); return isPlatformBrowser(platformId) ? localStorage : null; }, }); // Usage @Injectable({ providedIn: 'root' }) export class StorageService { 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('products'); @Injectable({ providedIn: 'root' }) export class ProductService { private http = inject(HttpClient); private transferState = inject(TransferState); private platformId = inject(PLATFORM_ID); getProducts(): Observable { // 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('/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).