Setting Up SSR
Adding server-side rendering to a modern Angular app is a single command away. The @angular/ssr package wires up a Node server entry, an index.csr.html fallback, and the build targets needed to render your routes on the server and hydrate them in the browser. This page walks through enabling SSR on a new or existing project, explains the files the schematic generates, and shows the build configuration that ties everything together.
Enabling SSR with ng add
The fastest path is the official schematic. From the root of an existing standalone application, run:
ng add @angular/ssr
This installs @angular/ssr and express, updates angular.json, scaffolds the server bootstrap, and turns on client hydration. If you are starting fresh, you can opt into SSR directly at project creation:
ng new my-app --ssr
After the schematic finishes, build and serve the SSR bundle to confirm everything is wired up:
ng build
node dist/my-app/server/server.mjs
Output:
Node Express server listening on http://localhost:4000
The schematic is idempotent for standalone apps on Angular 17+. If your app still uses
NgModulebootstrapping, migrate tobootstrapApplicationfirst — the SSR tooling targets the standalone API.
What the schematic generates
The schematic adds a handful of files and edits a few existing ones. Understanding each one makes debugging far easier.
| File | Purpose |
|---|---|
src/main.server.ts | Server bootstrap that returns the application ref for rendering. |
src/server.ts | Express server that renders requests and serves static assets. |
src/app/app.config.server.ts | Server-only providers, merged with the browser config. |
src/index.html → index.csr.html | Client-side render fallback used for hydration. |
The browser config is also updated to register hydration. Your app.config.ts now calls provideClientHydration():
import { ApplicationConfig } from '@angular/core';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideClientHydration(withEventReplay()),
],
};
The server bootstrap
main.server.ts exposes a bootstrap function that the rendering engine invokes per request. It mirrors the client bootstrap but pulls in config.server.ts:
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server';
const bootstrap = () => bootstrapApplication(AppComponent, config);
export default bootstrap;
The server config merges browser providers with server-specific ones via mergeApplicationConfig:
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/ssr';
import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = {
providers: [provideServerRendering()],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
The Express server entry
server.ts is the Node entry point. It uses AngularNodeAppEngine to render incoming requests and falls back to serving static files for assets. This is where you add custom middleware, REST endpoints, or caching headers.
import { AngularNodeAppEngine, createNodeRequestHandler, isMainModule, writeResponseToNodeResponse } from '@angular/ssr/node';
import express from 'express';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const app = express();
const angularApp = new AngularNodeAppEngine();
app.use(express.static(browserDistFolder, { maxAge: '1y', index: false, redirect: false }));
app.use((req, res, next) => {
angularApp
.handle(req)
.then((response) =>
response ? writeResponseToNodeResponse(response, res) : next()
)
.catch(next);
});
if (isMainModule(import.meta.url)) {
const port = process.env['PORT'] || 4000;
app.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
export const reqHandler = createNodeRequestHandler(app);
Build configuration
The schematic updates the build target in angular.json so a single ng build produces both browser/ and server/ bundles. The key additions are server, ssr, and outputMode:
{
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": "dist/my-app",
"browser": "src/main.ts",
"server": "src/main.server.ts",
"ssr": { "entry": "src/server.ts" },
"outputMode": "server"
}
}
}
}
The outputMode option controls how routes are emitted. Use "server" for fully dynamic SSR, or "static" to prerender everything at build time. A per-route app.routes.server.ts lets you mix both strategies.
| Option | Effect |
|---|---|
server | Routes rendered on demand by the Node server. |
static | All routes prerendered to HTML at build time (SSG). |
prerender (per route) | Force individual routes to prerender within a server build. |
During development, ng serve runs Vite with SSR enabled automatically, so you get HMR while still exercising the server render path.
Do not commit
dist/. The server bundle is environment-specific — build it in CI and ship thedist/<app>/serveranddist/<app>/browserfolders to your runtime.
Best Practices
- Migrate to standalone
bootstrapApplicationbefore runningng add @angular/ssr; the schematic does not support legacyNgModuleroots. - Keep all server-only providers in
app.config.server.tsand never import Node modules into shared components — they will break the browser build. - Pair
provideClientHydration()withwithEventReplay()so clicks fired before hydration are not lost. - Add
express.staticwith a longmaxAgefor fingerprinted assets, but keepindex: falseso the Angular engine owns HTML responses. - Use
outputMode: "static"for marketing pages and"server"for authenticated or personalized routes via per-route server config. - Verify the production bundle locally with
node dist/<app>/server/server.mjsbefore deploying.