Skip to content
Angular ng ssr 4 min read

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 NgModule bootstrapping, migrate to bootstrapApplication first — 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.

FilePurpose
src/main.server.tsServer bootstrap that returns the application ref for rendering.
src/server.tsExpress server that renders requests and serves static assets.
src/app/app.config.server.tsServer-only providers, merged with the browser config.
src/index.htmlindex.csr.htmlClient-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.

OptionEffect
serverRoutes rendered on demand by the Node server.
staticAll 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 the dist/<app>/server and dist/<app>/browser folders to your runtime.

Best Practices

  • Migrate to standalone bootstrapApplication before running ng add @angular/ssr; the schematic does not support legacy NgModule roots.
  • Keep all server-only providers in app.config.server.ts and never import Node modules into shared components — they will break the browser build.
  • Pair provideClientHydration() with withEventReplay() so clicks fired before hydration are not lost.
  • Add express.static with a long maxAge for fingerprinted assets, but keep index: false so 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.mjs before deploying.
Last updated June 14, 2026
Was this helpful?