Server-Side Rendering (SSR) in Angular
Angular Universal (now built into Angular itself as of v17) enables Server-Side Rendering (SSR) — the ability to render your Angular application on the server and send fully-formed HTML to the browser.
SSR dramatically improves first contentful paint, SEO, and social media previews by delivering pre-rendered HTML before JavaScript loads.
Why Use SSR?
Concern | Without SSR (CSR) | With SSR |
|---|---|---|
Initial page load | Blank page until JS runs | Pre-rendered HTML immediately |
SEO | Crawlers may miss content | Full HTML available to crawlers |
Social previews | Empty og:tags | Correct meta tags rendered |
Core Web Vitals | Slower LCP | Faster LCP and FCP |
JavaScript required | Yes | No — works without JS (progressive) |
Adding SSR to a New Project
Angular 17+ generates SSR-ready projects by default. When creating a new project, the CLI prompts:
ng new my-app # Would you like to enable Server-Side Rendering (SSR) and Static Site Generation (SSG/Prerendering)? # > Yes
This creates the project with @angular/ssr configured automatically.
Adding SSR to an Existing Project
ng add @angular/ssr
This command:
- Installs
@angular/ssrandexpress - Creates
server.ts(the Express app entry point) - Creates
app.config.server.ts(server-side app config) - Updates
angular.jsonwith SSR build targets
Project Structure After Adding SSR
my-app/ ├── src/ │ ├── app/ │ │ ├── app.config.ts # Browser config │ │ ├── app.config.server.ts # Server config (merges with browser) │ │ └── app.routes.ts │ ├── main.ts # Browser bootstrap │ └── main.server.ts # Server bootstrap ├── server.ts # Express server entry point └── angular.json # Updated with SSR targets
app.config.server.ts
The server config extends the browser config with server-specific providers:
// app.config.server.ts
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
// Add server-only providers here
],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);The Express Server (server.ts)
// server.ts
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
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',
}));
// Render 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(`Node Express server listening on http://localhost:${port}`);
});
}
run();Building and Running SSR
# Build for SSR ng build # The build produces two outputs: # dist/my-app/browser/ — client-side bundle # dist/my-app/server/ — server-side bundle # Run the server node dist/my-app/server/server.mjs
Node Express server listening on http://localhost:4000
Detecting Server vs. Browser
Some operations (localStorage, window, document) are only available in the browser.
Use isPlatformBrowser / isPlatformServer to guard them:
import { Component, OnInit, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
@Component({ selector: 'app-root', template: '' })
export class AppComponent implements OnInit {
private platformId = inject(PLATFORM_ID);
ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
// Safe to access window, localStorage, etc.
const theme = localStorage.getItem('theme');
console.log('Running in browser');
}
if (isPlatformServer(this.platformId)) {
// Runs on server only
console.log('Running on server');
}
}
}window, document, or localStorage directly in SSR-enabled apps. Always guard with isPlatformBrowser or the afterNextRender hook.Using afterNextRender and afterRender
Angular 16+ provides afterNextRender and afterRender lifecycle hooks that only run in the browser,
making them perfect for DOM-dependent code:
import { Component, afterNextRender, ElementRef, inject } from '@angular/core';
@Component({
standalone: true,
selector: 'app-chart',
template: `<canvas #canvas></canvas>`,
})
export class ChartComponent {
private el = inject(ElementRef);
constructor() {
// Only runs in the browser, after the first render
afterNextRender(() => {
const canvas = this.el.nativeElement.querySelector('canvas');
// Initialize chart library here — safe, we're in the browser
initializeChart(canvas);
});
}
}Static Site Generation (SSG / Prerendering)
SSG pre-renders pages at build time rather than per-request. This is ideal for content that doesn't change per-user (blog posts, docs, marketing pages).
Configure prerendering in angular.json:
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"prerender": {
"routesFile": "routes.txt"
}
}
}
}
}
}
}# routes.txt — list all routes to prerender / /about /blog/post-1 /blog/post-2 /products
Or configure dynamic routes programmatically:
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: 'blog/:slug',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
const posts = await fetchBlogSlugs(); // your API call
return posts.map((slug) => ({ slug }));
},
},
{
path: 'dashboard',
renderMode: RenderMode.Server, // SSR per-request
},
{
path: '**',
renderMode: RenderMode.Prerender, // SSG for everything else
},
];Hydration
Angular's non-destructive hydration (stable in Angular 17) preserves the server-rendered DOM during client-side bootstrap instead of re-rendering from scratch. This prevents content flicker and improves Core Web Vitals.
Enable it in your browser app config:
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideClientHydration } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideClientHydration(), // Enable non-destructive hydration
],
};Math.random() or Date.now()during initial render.HTTP Transfer State
Without transfer state, data fetched on the server gets refetched on the client — wasting a round trip.
The HttpClient in Angular automatically handles transfer state when SSR is enabled:
// app.config.ts
import { provideHttpClient, withFetch } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withFetch()), // Uses fetch API on both server and browser
provideClientHydration(),
],
};With withFetch(), Angular automatically transfers HTTP responses from server to client so the
browser doesn't make duplicate API calls.
Deploying SSR Apps
SSR apps require a Node.js environment. Common deployment options:
Node.js server (VPS, AWS EC2, DigitalOcean) — run server.mjs directly
Docker container — containerize and deploy to Kubernetes, ECS, etc.
Google Cloud Run — serverless containers for Node.js
Vercel / Netlify — automatic SSR detection and deployment
Firebase Hosting + Cloud Functions — deploy with firebase deploy
# Example: Docker deployment FROM node:20-alpine WORKDIR /app COPY dist/my-app/ ./ EXPOSE 4000 CMD ["node", "server/server.mjs"]
Common SSR Pitfalls
Pitfall | Solution |
|---|---|
Accessing window/document | Guard with isPlatformBrowser or afterNextRender |
Content flicker on hydration | Enable provideClientHydration() |
Server/client HTML mismatch | Avoid random values or Date.now() in templates |
Memory leaks in server | Avoid storing state in singleton services across requests |
Slow TTFB | Add HTTP caching headers, use CDN for static assets |
Third-party libraries failing | Check for browser-only APIs, use isPlatformBrowser |
SSR Checklist
Add @angular/ssr to your project with ng add @angular/ssr
Guard all browser-only APIs with isPlatformBrowser
Enable provideClientHydration() in app.config.ts
Use provideHttpClient(withFetch()) for automatic transfer state
Use afterNextRender for DOM-dependent initialization
Test locally with ng build && node dist/.../server.mjs
Configure prerendering routes for static content
Set up proper HTTP caching headers on the server