angular-ssr
Angular SSR
Implement server-side rendering, hydration, and prerendering in Angular v20+.
Setup
Add SSR to Existing Project
ng add @angular/ssr
This adds:
@angular/ssrpackageserver.ts- Express serversrc/main.server.ts- Server bootstrapsrc/app/app.config.server.ts- Server providers- Updates
angular.jsonwith 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
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,
},
];
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():
// app.config.ts
import { provideClientHydration } from "@angular/platform-browser";
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(),
// ...
],
};
Incremental Hydration
Defer hydration of specific components:
@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:
import {
provideClientHydration,
withEventReplay,
} from "@angular/platform-browser";
export const appConfig: ApplicationConfig = {
providers: [provideClientHydration(withEventReplay())],
};
Browser-Only Code
Platform Detection
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:
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);
}
}
Prerendering
Static Routes
// 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
},
];
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:
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
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
# 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");
});
For advanced patterns, see references/ssr-patterns.md.
More from danielsogl/copilot-workflow-demo
ngrx-store
Use when creating NgRx Signals Stores for state management. Triggers on requests to "create store", "add state management", "new store", "signal store", or when implementing state patterns with NgRx Signals.
9skill-creator
Create new skills, modify and improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, edit, or optimize an existing skill, run evals to test a skill, benchmark skill performance with variance analysis, or optimize a skill's description for better triggering accuracy.
2webapp-testing
Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.
1mcp-builder
Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).
1angular-testing
Write unit and integration tests for Angular v21+ applications using Vitest or Jasmine with TestBed, component harnesses, and modern testing patterns. Use for testing components with signals, OnPush change detection, services with inject(), and HTTP interactions. Triggers on test creation, testing signal-based components, mocking dependencies, or setting up test infrastructure.
1openspec-explore
Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
1