Implement server-side rendering, hydration, and prerendering in Angular v20+. ``` ng add @angular/ssr
ng add @angular/ssr@angular/ssr packageserver.ts - Express serversrc/main.server.ts - Server bootstrapsrc/app/app.config.server.ts - Server providersangular.json with SSR configurationsrc/ ├── 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
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` // 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, }, ];
RenderMode.PrerenderRenderMode.ServerRenderMode.ClientprovideClientHydration():// app.config.ts import { provideClientHydration } from '@angular/platform-browser'; export const appConfig: ApplicationConfig = { providers: [ provideClientHydration(), // ... ], };
@Component({ template: ` <!-- Hydrate when visible --> @defer (hydrate on viewport) { <app-comments [postId]="postId" /> } @placeholder { <div>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>(); }
hydrate on viewporthydrate on interactionhydrate on idlehydrate on immediatehydrate on timer(ms)hydrate when conditionhydrate neverimport { provideClientHydration, withEventReplay } from '@angular/platform-browser'; export const appConfig: ApplicationConfig = { providers: [ provideClientHydration(withEventReplay()), ], };
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); } } }
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` // 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); } }
// 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` // 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 }, ];
PrerenderFallback.ServerPrerenderFallback.ClientPrerenderFallback.Noneimport { 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` 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 with SSR ng build # Output structure dist/ ├── my-app/ │ ├── browser/ # Client assets │ └── server/ # Server bundle `### Run SSR Server` # Development npm run serve:ssr:my-app # Production node dist/my-app/server/server.mjs `### Deploy to Node.js Host` // 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'); });