Angular 21 SSR Local Development: What Changed, What Broke, and How to Fix It
Angular 21 SSR Local Development: What Changed, What Broke, and How to Fix It
A day of fighting Angular 21βs new SSR internals, documented so you donβt have to repeat it
Last updated: March 2026 | Angular 21.1.5 | Node.js 20
π‘ Note: This is the Angular 21 follow-up to our Angular 20 SSR Local Development guide. If youβre on Angular 20, read that one first β it covers the fundamentals and the Express + API proxy setup in detail. Come back here when you upgrade and things stop working.
When βIt Works in Angular 20β Stops Being True
Hereβs the scene: youβve got a working Angular 20 SSR local dev setup. Express server, renderApplication, main.js from the server bundle. You run npm run start:ssr:dev, it boots up, your app renders server-side, life is good.
Then you upgrade to Angular 21.
Nothing works.
Your main.js doesnβt exist anymore. Your renderApplication import throws TypeError: renderApplication is not a function. Your platform providers throw NG0201: No provider found for InjectionToken PlatformDestroyListeners. You add provideServerRendering to platformProviders, you add PLATFORM_ID, you import from different packages β and the same error keeps coming back. Then you try AngularNodeAppEngine from the docs and get a completely different error: βAngular app engine manifest is not set.β
That was my experience upgrading this project from Angular 20 to 21. This guide is the documentation I wish had existed.
Angular 21βs SSR architecture changed substantially. Not βrenamed one functionβ substantially β different file format, different import paths, different rendering API, different builder configuration substantially. The changes are real improvements for production deployments. But if youβre trying to maintain a working local dev setup through the upgrade, you need to understand exactly what changed and why.
Letβs walk through all of it.
What Youβre Getting Into
By the end of this guide youβll have:
- β
True Angular 21 SSR running locally using
CommonEngineandmain.server.mjs - β
A clear understanding of every breaking change between Angular 20 and 21 SSR, with links to relevant
angular.devdocumentation - β
Clarity on
AngularNodeAppEngineβ what it is, why itβs exciting, and why it doesnβt work for thets-nodelocal dev pattern yet - β
A working
server.tsthat loads the new ESM bundle correctly - β
All the
NG0201andNG0401errors explained with root causes and fixes - β API proxy forwarding to your local backend
The Big Picture: Legacy vs. Modern Angular SSR
Before touching any code, hereβs the full comparison of what changed:
ββββββββββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββ
β Older Setup (still works) β Recommended Setup (Angular 17+) β
ββββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββ€
β TWO builders: β ONE builder: β
β - @angular-devkit/build-angular β - @angular/build:application β
β :application (browser) β with ssr: true per configuration β
β - @angular-devkit/build-angular β β
β :server β β
ββββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββ€
β Server bundle: main.js (CommonJS) β Server bundle: main.server.mjs (ESM) β
ββββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββ€
β Index file: dist/browser/index.html β Index file: dist/browser/index.csr.htmlβ
ββββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββ€
β renderApplication exported from β renderApplication NOT exported β
β server bundle β import and call it β Use CommonEngine from @angular/ssr β
ββββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββ€
β provideServerRendering from: β provideServerRendering from: β
β @angular/platform-server β @angular/ssr (different package!) β
ββββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββ€
β Bootstrap: serverModule.default β Bootstrap: serverModule.default β
β .default (double-nested) β (single .default) β
ββββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββ€
β Build needs two commands: β Single command builds everything: β
β ng build + ng run :server:dev β ng build --configuration development β
ββββββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββββββββ€
β tsconfig: composite not needed β tsconfig: composite: true required β
β server files in separate tsconfig β server files IN tsconfig.app.json β
ββββββββββββββββββββββββββββββββββββββββ΄βββββββββββββββββββββββββββββββββββββββ
Eight things differ between the legacy and modern SSR setups. If youβre upgrading from an older project still using the deprecated server builder, these changes will affect your configuration. This guide walks through each one.
The Three Development Modes (Same Strategy, Different Wiring)
The fundamental local dev strategy hasnβt changed β there are still three ways to run your app, and you need the right one for each situation:
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β MODE 1: Standard Dev Server (npm start) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Command: ng serve β
β SSR: β No (client-side rendering only, in-memory) β
β Speed: β‘ Fast β best for UI work and component development β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β MODE 2: Local SSR Dev (npm run start:ssr:dev) β THIS GUIDE β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Command: npm run start:ssr:dev β
β SSR: β
Yes β disk build β CommonEngine β Express proxy β
β Speed: π’ Slower (~20-30s rebuilds) β
β Use for: SSR feature testing, auth flows, meta tags, crawlers β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β MODE 3: Production Build (npm run build:ssr) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Command: npm run build:ssr β
β SSR: β
Yes β optimized production bundles β
β Speed: π Slowest β run before deploying only β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Understanding Angular 21βs New SSR Architecture
The Builder Unified Into One
Note: The unified @angular/build:application builder was introduced in Angular 17 and became the recommended default for new projects. If youβre still using the older @angular-devkit/build-angular:server builder (which still works in Angular 20+), this section explains the differences and migration path.
The older setup uses a separate @angular-devkit/build-angular:server builder that produces a CommonJS main.js bundling renderApplication as an export alongside your bootstrap function. Predictable. Reliable. Slightly clunky.
The modern approach uses ONE builder β @angular/build:application β with SSR configured inside it per build configuration:
// Angular 21 β one builder, SSR in each configuration
"build": {
"builder": "@angular/build:application",
"configurations": {
"development": {
"server": "src/main.server.ts",
"ssr": true,
"optimization": false,
"sourceMap": true
},
"production": {
"server": "src/main.server.ts",
"ssr": true,
"optimization": true
}
}
}
π Angular docs β SSR guide: The unified
@angular/build:applicationbuilder handles both browser and server bundles in a single build pass. The"server"entry point and"ssr": trueenable server-side rendering for that configuration.
What this means for your workflow:
- No more
ng run project:server:developmentas a separate build step βng build --configuration developmentproduces both browser and server bundles - The
build:ssr:devnpm script gets simpler (and one fewer thing to configure) - But the output format changes, and everything in
server.tsthat depended on the old format breaks
The New Bundle: main.server.mjs (Not main.js)
The old server builder produced CommonJS output (main.js) β a regular .js file you could require() or import() like any Node module. Crucially, it packaged renderApplication into the output alongside your bootstrap function.
Angular 21βs unified builder produces an ES module: main.server.mjs. This file:
- Uses
import/exportsyntax (notrequire/module.exports) - Does NOT export
renderApplicationβ it only exports your bootstrap function and some Angular SSR internals - Must be loaded with dynamic
import()via afile://URL (required on Windows due to ESM URL resolution)
// Angular 20 dist structure
dist/frontend/server/
βββ main.js β CJS, exports bootstrap + renderApplication
// Angular 21 dist structure
dist/web/server/
βββ main.server.mjs β ESM, exports bootstrap only
βββ angular-app-manifest.mjs β New: consumed by AngularNodeAppEngine
βββ angular-app-engine-manifest.mjs β New: consumed by AngularNodeAppEngine
βββ polyfills.server.mjs
βββ chunk-XXXX.mjs
Those two new manifest files are worth understanding β theyβre why AngularNodeAppEngine exists and why it doesnβt quite work for our use case.
Why renderApplication() No Longer Works
In Angular 20, you imported renderApplication directly from the compiled server bundle:
// Angular 20 β server bundle exported renderApplication
const serverModule = await import(serverBundlePath);
const { renderApplication } = serverModule.default; // β returned a function
const bootstrap = serverModule.default.default;
In Angular 21, serverModule.default.renderApplication is undefined. The function still exists in @angular/platform-server, but the new build system doesnβt re-export it from your application bundle.
π renderApplication API: Still available in
@angular/platform-server, but Angular 21βs@angular/build:applicationno longer packages it into your appβs server bundle output. The Angular teamβs answer is to useCommonEngineorAngularNodeAppEngineinstead.
Meet AngularNodeAppEngine (And Why It Doesnβt Work Here)
When you read the Angular 21 SSR docs, AngularNodeAppEngine is front and center as the recommended server API:
import { AngularNodeAppEngine, writeResponseToNodeResponse } from '@angular/ssr/node';
const angularApp = new AngularNodeAppEngine();
app.use('*', (req, res, next) => {
angularApp.handle(req)
.then(response => {
if (response) writeResponseToNodeResponse(response, res);
else next();
})
.catch(next);
});
π AngularNodeAppEngine API: The official Angular 21 API for Node.js SSR servers. Automatically handles manifest loading, route resolution, and platform setup.
This looks clean. No bootstrap loading. No explicit documentFilePath. No platform providers. The engine handles everything automatically.
So why arenβt we using it?
AngularNodeAppEngine reads angular-app-engine-manifest.mjs at startup to discover your appβs entry points and routes. This manifest lives at dist/web/server/angular-app-engine-manifest.mjs β generated by the build.
When you run ts-node --esm server.ts from your project root, AngularNodeAppEngine looks for the manifest relative to where Node.js is running β your project root, not dist/web/server/. The result is:
Error: Angular app engine manifest is not set. Please ensure you are using
the '@angular/build:application' builder to build your server application.
The manifest is there. The builder is @angular/build:application. Angular just canβt locate the manifest because the server process is running from the wrong directory relative to the dist output.
Angular CLIβs ng serve performs SSR differently β it uses main.server.ts directly in-memory and does not go through AngularNodeAppEngine or the manifest at all. The manifest-based approach is intended for production Node.js server deployments. When youβre running a custom Express server from the project root with ts-node, the manifest resolution fails.
AngularNodeAppEngine is the right answer for production and for server deployments where the process runs from the dist folder. For the ts-node --esm server.ts local dev pattern from the project root, CommonEngine is the correct tool right now.
The Solution: CommonEngine
CommonEngine from @angular/ssr/node is the stable, explicitly-controlled rendering engine for Angular in custom Node.js servers. You load your bootstrap function from the compiled bundle and pass everything explicitly:
import { CommonEngine } from '@angular/ssr/node';
const commonEngine = new CommonEngine();
commonEngine.render({
bootstrap, // Loaded from dist/web/server/main.server.mjs
documentFilePath, // Path to dist/web/server/index.server.html
url, // Full request URL
publicPath, // Path to dist/web/browser
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }]
})
π CommonEngine API: Officially supported for custom Node.js servers. Works regardless of which directory your server process runs from, because you provide all paths explicitly.
Prerequisites
Tools
node --version # v20.x or higher recommended
ng version # Angular CLI 21.x
Install SSR Dev Dependencies
cd apps/web
npm install --save-dev cross-env ts-node
Backend
Your local backend should run on localhost:3000. (This project uses 3000 instead of the older 4000 β adjust the proxy target in server.ts if yours differs.)
Project Structure
apps/web/
βββ src/
β βββ main.ts # Browser entry point
β βββ main.server.ts # Server entry (MUST accept BootstrapContext)
β βββ app/
β β βββ app.config.ts # Client app config
β β βββ app.config.server.ts # Server config (provideServerRendering from @angular/ssr)
β βββ environments/
β βββ environment.ts # Production env
β βββ environment.development.ts # Dev env (apiUrl: '/api')
βββ server.ts # Express SSR server (uses CommonEngine)
βββ angular.json # Single builder with ssr: true in configs
βββ tsconfig.json # Base TypeScript config
βββ tsconfig.app.json # composite: true + server files included
βββ tsconfig.server.json # composite: true
βββ package.json
βββ scripts/
βββ copy-index.js # Post-build: copies index.csr.html β index.server.html
Configuration Files
angular.json β One Builder, SSR in Each Configuration
The entire "server" architect target block from Angular 20 is gone. SSR now lives inside the "build" targetβs configurations via the "server" and "ssr" properties:
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"projects": {
"web": {
"projectType": "application",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": "dist/web",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [{ "glob": "**/*", "input": "public" }],
"styles": ["src/styles.scss"],
"scripts": []
},
"configurations": {
"production": {
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractLicenses": true,
"namedChunks": false,
"server": "src/main.server.ts",
"ssr": true,
"fileReplacements": [
{ "replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts" }
]
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"server": "src/main.server.ts",
"ssr": true,
"fileReplacements": [
{ "replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts" }
]
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": { "buildTarget": "web:build:production" },
"development": { "buildTarget": "web:build:development" }
},
"defaultConfiguration": "development"
}
}
}
}
}
π Angular docs β SSR guide:
"server"points to yourmain.server.tsentry point and"ssr": trueenables server bundle output. Both must be present in every configuration where you want SSR output β development and production alike.
Angular 20 comparison: You needed a separate "server" architect target with its own @angular-devkit/build-angular:server builder, tsConfig, and outputPath. In Angular 21, delete the entire "server" target β the unified builder handles it.
tsconfig.app.json β composite: true and Server Entry Files
Angular 21 uses TypeScript project references internally. Project references require "composite": true in every referenced tsconfig. Without it the build fails with:
error TS6306: Referenced project '...tsconfig.app.json' must have setting "composite": true.
Also, the unified builder requires your server entry files to be listed in tsconfig.app.jsonβs files array β otherwise youβll see warnings about files outside the TypeScript program:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": [],
"composite": true
},
"files": [
"src/main.ts",
"src/main.server.ts",
"src/app/app.config.server.ts"
],
"include": ["src/**/*.d.ts"],
"exclude": ["server.ts", "src/**/*.spec.ts"]
}
Angular 20 comparison: Angular 20βs tsconfig.app.json only listed src/main.ts in files. Server files lived in the separate tsconfig.server.json for the now-removed server builder. Since that builder is gone, server files now belong in tsconfig.app.json.
tsconfig.server.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/server",
"target": "ES2022",
"module": "ESNext",
"types": ["node"],
"composite": true
}
}
tsconfig.spec.json
Add "composite": true to this one too β otherwise project reference resolution fails:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": ["vitest/globals"],
"composite": true
}
}
Angular Application Files
src/main.server.ts
The entry point for server-side rendering. The BootstrapContext parameter is mandatory β without it you get NG0401: Missing Platform because Angularβs SSR engine canβt inject the server platform context:
import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { config } from './app/app.config.server';
const bootstrap = (context: BootstrapContext) => bootstrapApplication(App, config, context);
export default bootstrap;
π BootstrapContext API: The context object Angularβs server engine passes to
bootstrapApplicationduring SSR. Contains platform-level providers including the request URL, document, and server-side platform tokens. Without it, those providers canβt be injected into your application tree.
This file is essentially the same between Angular 20 and 21. If your Angular 20 project already used BootstrapContext, no changes are needed.
src/app/app.config.server.ts β The Import That Breaks Everything
This is where most Angular 21 SSR migrations fail. The provideServerRendering function moved packages:
// β Pre-Angular 20 import β causes NG0201 PlatformDestroyListeners error
import { provideServerRendering } from '@angular/platform-server';
// β
Angular 20+ correct import
import { provideServerRendering } from '@angular/ssr';
The function name and call signature are identical. Only the package changed. But using the old @angular/platform-server import results in this cryptic error:
NG0201: No provider found for `InjectionToken PlatformDestroyListeners`.
Source: Platform: core.
The reason: @angular/ssrβs provideServerRendering registers the complete Angular 21 server platform provider chain, including PlatformDestroyListeners. The @angular/platform-server version no longer does this in Angular 21 β itβs either been stripped down or is incompatible with Angular 21βs revised platform internals.
Basic Configuration (Minimal Setup)
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/ssr'; // NOT @angular/platform-server
import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering()
]
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
π provideServerRendering API: Moved to
@angular/ssrin Angular 20. Configures server-side rendering including support for server routes, app shell, transfer state, and all internal server platform tokens.
Advanced Configuration: Hybrid Rendering with Server Routes
While the basic setup works, provideServerRendering() accepts optional feature parameters that give you fine-grained control over how each route is rendered. This is called hybrid rendering β mixing SSR, prerendering (SSG), and client-side rendering based on route requirements.
Why configure server routes?
Without withRoutes(), Angular uses default behavior:
- All parametrized routes β SSR
- All non-parametrized routes β Prerendered (SSG)
For applications with auth, dashboards, and public pages, you should explicitly configure rendering modes for optimal performance and security.
Step 1: Create src/app/app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
// Public pages - prerendered for SEO and performance
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'about', renderMode: RenderMode.Prerender },
{ path: 'terms', renderMode: RenderMode.Prerender },
{ path: 'privacy', renderMode: RenderMode.Prerender },
// Auth pages - client-side only
{ path: 'login', renderMode: RenderMode.Client },
{ path: 'register', renderMode: RenderMode.Client },
{ path: 'forgot-password', renderMode: RenderMode.Client },
// Protected routes - server-rendered for user-specific data
{ path: 'dashboard', renderMode: RenderMode.Server },
{ path: 'settings', renderMode: RenderMode.Server },
// Fallback
{ path: '**', renderMode: RenderMode.Server }
];
π ServerRoute API: Defines rendering strategy per route. Three modes available:
RenderMode.Prerender(SSG): Static HTML generated at build timeRenderMode.Server(SSR): Rendered on-demand per requestRenderMode.Client(CSR): Server sends shell, browser renders everything
Step 2: Update app.config.server.ts to use withRoutes
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(withRoutes(serverRoutes))
]
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
π withRoutes API: Configures server-side routing for the application. Registers an array of
ServerRoutedefinitions, enabling per-route rendering strategies.
Step 3: Add app.routes.server.ts to TypeScript compilation
Update tsconfig.app.json to include the new server routes file:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": [],
"composite": true
},
"files": [
"src/main.ts",
"src/main.server.ts",
"src/app/app.config.server.ts",
"src/app/app.routes.server.ts"
],
"include": ["src/**/*.d.ts"],
"exclude": ["server.ts", "src/**/*.spec.ts"]
}
Benefits of this approach:
- Performance β Static pages load instantly (prerendered)
- SEO β Public pages fully indexed by search engines
- Security β Auth pages donβt expose server logic
- Efficiency β Only dynamic pages use server resources
- User Experience β Optimal rendering strategy per route type
Understanding the render modes:
RenderMode.Prerender: HTML generated once at build time and served as static files. Best for content that rarely changes (landing pages, about, terms).RenderMode.Server: HTML rendered on every request. Use for user-specific or frequently changing content (dashboards, user profiles).RenderMode.Client: Server sends minimal shell; browser renders everything. Use for auth flows or pages that shouldnβt be server-rendered.
π‘ Note: Your client-side routes (
app.routes.ts) remain unchanged. Theapp.routes.server.tsfile only controls how each route is rendered, not the route structure itself.
Build Output Structure
After ng build --configuration development, the dist folder looks like this:
dist/web/
βββ browser/
β βββ index.csr.html β NEW name (was index.html in Angular 20)
β βββ main.js # Browser app bundle
β βββ styles.css
β βββ assets/
βββ server/
βββ main.server.mjs β NEW name and format (was main.js CJS in Angular 20)
βββ polyfills.server.mjs
βββ angular-app-manifest.mjs
βββ angular-app-engine-manifest.mjs
βββ index.server.html β Copied here by scripts/copy-index.js
βββ chunk-XXXX.mjs
Two filename changes to be aware of:
index.htmlβindex.csr.html: Theapplicationbuilder names the browser fallback HTML this way. CSR = Client-Side Rendering. The name distinguishes the un-rendered HTML template from any server-rendered output. Yourcopy-index.jsscript must handle this new name.main.jsβmain.server.mjs: The server bundle is ESM. The.mjsextension makes this explicit and is required for correct Node.js module resolution.
Environment Files (Same Pattern, No Changes Needed)
// src/environments/environment.development.ts
export const environment = {
production: false,
apiUrl: '/api', // Proxied to local backend by Express
};
// src/environments/environment.ts (production)
export const environment = {
production: true,
apiUrl: 'https://api.yourdomain.app/api',
};
Verify the dev build uses /api before starting the server:
grep "apiUrl" dist/web/browser/main.js
# Should show: apiUrl:"/api"
Express Server Setup (server.ts)
This is the most heavily changed file compared to Angular 20. Hereβs the complete working version, followed by a line-by-line breakdown of every change:
import 'zone.js/node';
import '@angular/compiler'; // MUST be first, before any Angular imports
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr/node';
import express, { type Express, type Request, type Response, type NextFunction } from 'express';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import { request as httpRequest } from 'node:http';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export async function app(): Promise<Express> {
const server = express();
const distFolder = resolve(__dirname, 'dist/web');
const browserDistFolder = join(distFolder, 'browser');
const serverDistFolder = join(distFolder, 'server');
const indexHtml = join(serverDistFolder, 'index.server.html');
// Load bootstrap from compiled .mjs bundle β NOT from src/
const serverBundlePath = pathToFileURL(join(serverDistFolder, 'main.server.mjs')).href;
const serverModule = await import(serverBundlePath);
const bootstrap = serverModule.default; // Single .default (not .default.default)
const commonEngine = new CommonEngine();
server.set('view engine', 'html');
server.set('views', browserDistFolder);
// Proxy /api/* to backend β proxy.conf.json is ignored by custom Express servers
server.use('/api', (req: Request, res: Response) => {
const targetPath = `/api${req.url}`;
console.log(`[PROXY] ${req.method} ${targetPath} β http://localhost:3000`);
const options = {
hostname: 'localhost',
port: 3000,
path: targetPath,
method: req.method,
headers: req.headers
};
const proxyReq = httpRequest(options, (proxyRes) => {
console.log(`[PROXY] Response: ${proxyRes.statusCode}`);
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
proxyRes.pipe(res);
});
proxyReq.on('error', (err) => {
console.error('[PROXY] Error:', err);
res.status(500).json({ error: 'Proxy error' });
});
req.pipe(proxyReq);
});
server.get('/health', (req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Serve static files with no caching during development
server.get('*.*', express.static(browserDistFolder, {
maxAge: 0,
etag: false,
setHeaders: (res) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
}
}));
// SSR handler for all application routes
server.get('*', (req: Request, res: Response, next: NextFunction) => {
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(next);
});
return server;
}
async function run(): Promise<void> {
const port = process.env['PORT'] || 8201;
const server = await app();
server.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
if (!process.env.NETLIFY && !process.env.VERCEL) {
run().catch(err => {
console.error('Failed to start server:', err);
process.exit(1);
});
}
Angular 20 vs. Angular 21 server.ts: What Changed and Why
| What | Angular 20 | Angular 21 |
|---|---|---|
| Rendering API | renderApplication() from server bundle | CommonEngine from @angular/ssr/node |
| Server bundle filename | main.js | main.server.mjs |
| Bundle format | CommonJS | ESM (must use pathToFileURL) |
| Bootstrap extraction | serverModule.default.default (nested) | serverModule.default (single) |
renderApplication source | serverModule.default.renderApplication | Not needed β CommonEngine handles it |
| Document input | Read file to string, pass as document: | Pass path directly as documentFilePath: |
| Provider key | platformProviders: [...] | providers: [...] |
| Index HTML path | dist/frontend/browser/index.html | dist/web/server/index.server.html |
Key callouts for each change:
@angular/compiler must be first: Still required in Angular 21. If you donβt import it before Angular code, youβll hit:
The injectable 'PlatformLocation' needs to be compiled using the JIT compiler,
but '@angular/compiler' is not available.
Put it on line 2, right after zone.js/node. This was true in Angular 20 too, but itβs easy to forget when rewriting server.ts.
Load from bundle, not src: This is a new Angular 21 trap. In Angular 20 you could sometimes import from source files. In Angular 21, you must load from dist/web/server/main.server.mjs. Importing from src/main.server.ts directly causes:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../src/main.server.js'
The builder doesnβt copy source files to a loadable location β load only from the compiled bundle.
Single .default not .default.default: In the Angular 20 server bundle (CommonJS), the bootstrap function was nested as module.exports.default.default. In the Angular 21 ESM bundle, the default export is your bootstrap function directly β serverModule.default.
documentFilePath not document: CommonEngine.render() takes a file path string (documentFilePath) and reads it internally. Angular 20βs renderApplication() required you to readFile the HTML yourself and pass the string as document. Less code to write, and no async file reading in the route handler.
providers not platformProviders: CommonEngine.render() uses providers (not platformProviders). Both accept the same provider syntax. APP_BASE_HREF goes here.
π CommonEngine.render() options: Full API reference for all render options including
bootstrap,documentFilePath,url,publicPath,inlineCriticalCss, andproviders.
Build Scripts
package.json
{
"scripts": {
"start": "ng serve --port 8201",
"start:ssr:dev": "npm run build:ssr:dev && cross-env PORT=8201 ts-node --esm server.ts",
"build:ssr:dev": "ng build --configuration development && node scripts/copy-index.js",
"build": "ng build",
"build:ssr": "ng build --configuration production && node scripts/copy-index.js"
}
}
Angular 20 comparison:
// Angular 20 β required a separate server build step
"build:ssr:dev": "ng build --configuration development && ng run frontend:server:development && node scripts/copy-index.js"
The ng run frontend:server:development step is gone in Angular 21. ng build --configuration development now builds both browser and server bundles in one pass. Your build script gets shorter, and thereβs one fewer target to configure in angular.json.
The copy-index Script
This script is conceptually the same as in Angular 20, but must handle the renamed index.csr.html output file:
// scripts/copy-index.js
const fs = require('fs');
const path = require('path');
// Angular 21 produces index.csr.html, not index.html
const browserIndexCandidates = [
path.join(__dirname, '../dist/web/browser/index.html'),
path.join(__dirname, '../dist/web/browser/index.csr.html'),
];
const serverDir = path.join(__dirname, '../dist/web/server');
const serverDestFile = path.join(serverDir, 'index.server.html');
try {
const sourceFile = browserIndexCandidates.find((candidate) => fs.existsSync(candidate));
if (!sourceFile) {
throw new Error('Angular build did not produce index.html or index.csr.html in browser dist');
}
if (!fs.existsSync(serverDir)) {
fs.mkdirSync(serverDir, { recursive: true });
}
fs.copyFileSync(sourceFile, serverDestFile);
console.log(`β Copied ${path.basename(sourceFile)} β server/index.server.html`);
} catch (err) {
console.error('β copy-index failed:', err.message);
process.exit(1);
}
Why check for both names? The application builder produces index.csr.html in Angular 21. But the script checks both names so it doesnβt break if Angular changes the naming convention again in Angular 22.
Why copy to server/index.server.html? CommonEngine reads the document from disk. Keeping it in the server dist folder avoids cross-directory path issues and makes the path logic in server.ts straightforward.
Angular 20 comparison: The Angular 20 version copied dist/frontend/browser/index.html to dist/frontend/server/index.server.html. The logic is identical β only the folder names and the source filename changed.
Verification: Is SSR Actually Running?
After npm run start:ssr:dev, verify each layer is working:
1. Server Started
Node Express server listening on http://localhost:8201
If you see this, Express is running. If it crashes, check the error β the most common causes are covered in the next section.
2. SSR is Actually Rendering (Not Just Serving HTML)
curl http://localhost:8201 | grep "<app-root"
# SSR working: returns <app-root><div ...>actual content...</div></app-root>
# SSR broken: returns <app-root></app-root>
If <app-root> is empty, SSR rendering failed silently. Check the Express terminal for SSR rendering error: messages.
3. Styles Are Loading
Open http://localhost:8201 in a browser. If the page has no styles, the copy-index.js script likely copied src/index.html (with no bundled styles) instead of dist/web/browser/index.csr.html (with <link> to compiled styles). Check the Express static file logs.
4. API Proxy Is Working
curl http://localhost:8201/api/health
# Should forward to http://localhost:3000/api/health and return your backend response
Check the Express terminal for [PROXY] log lines. If you donβt see them, the proxy middleware isnβt being hit.
5. Environment Is Development
grep "apiUrl" dist/web/browser/main.js | head -1
# Should contain: apiUrl:"/api"
# If it shows your production URL, the fileReplacements didn't apply
Errors Youβll Hit (And Why)
These are the specific errors encountered when upgrading from Angular 20 to 21, in roughly the order youβll encounter them. Each one has a root cause thatβs different from what the error message implies.
Error 1: NG0201 β No provider found for PlatformDestroyListeners
ERROR [Error]: NG0201: No provider found for `InjectionToken PlatformDestroyListeners`.
Source: Platform: core.
Root cause: Youβre importing provideServerRendering from @angular/platform-server instead of @angular/ssr.
Fix: Change the import in app.config.server.ts:
// Change this:
import { provideServerRendering } from '@angular/platform-server';
// To this:
import { provideServerRendering } from '@angular/ssr';
This error appears in Angular 20 and later when using the old @angular/platform-server import path. The package move happened in Angular 20 β if you ran ng update at that point, the migration schematic would have already fixed this. In Angular 20+, @angular/platform-serverβs provideServerRendering no longer registers the full platform provider chain.
Error 2: renderApplication is not a function
TypeError: renderApplication is not a function
or
TypeError: Cannot destructure property 'renderApplication' of 'serverModule.default'
as it is undefined.
Root cause: Youβre trying to import renderApplication from the main.server.mjs bundle, which no longer exports it.
Fix: Switch to CommonEngine. Remove the renderApplication import and use CommonEngine.render() as shown in the server.ts above. The rendering capability is the same β just invoked differently.
Error 3: Cannot find module main.server.js
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'C:\...\apps\web\dist\web\server\main.server.js'
imported from ...
Root cause: Your server.ts is pointing to main.server.js (from Angular 20) but Angular 21 produces main.server.mjs.
Fix: Update the bundle path:
// Angular 20
const serverBundlePath = pathToFileURL(join(serverDistFolder, 'main.server.js')).href;
// Angular 21
const serverBundlePath = pathToFileURL(join(serverDistFolder, 'main.server.mjs')).href;
Error 4: Angular app engine manifest is not set
Error: Angular app engine manifest is not set. Please ensure you are using
the '@angular/build:application' builder to build your server application.
Root cause: Youβre using AngularNodeAppEngine and running ts-node server.ts from the project root. The manifest file exists at dist/web/server/angular-app-engine-manifest.mjs but AngularNodeAppEngine canβt locate it relative to the process working directory.
Fix: Switch to CommonEngine as described in this guide. If you want to use AngularNodeAppEngine specifically, youβd need to run the server from within the dist folder β which defeats the purpose of the local dev setup.
Error 5: JIT compilation failed β @angular/compiler not available
Error: The injectable 'PlatformLocation' needs to be compiled using the JIT compiler,
but '@angular/compiler' is not available.
JIT compilation failed for injectable [PlatformLocation class PlatformLocation]
Root cause: import '@angular/compiler' is missing or not at the top of server.ts.
Fix: Add it as the second import in server.ts, immediately after zone.js/node:
import 'zone.js/node';
import '@angular/compiler'; // β must be here, before any other Angular imports
import { APP_BASE_HREF } from '@angular/common';
Error 6: Cannot find module src/main.server.js (not .mjs)
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'C:\...\apps\web\src\main.server.js'
Root cause: Youβre importing from the source directory (src/main.server.ts) instead of the compiled bundle.
Fix: Never import from src/ in server.ts. Always load from the dist bundle:
const serverBundlePath = pathToFileURL(join(serverDistFolder, 'main.server.mjs')).href;
const serverModule = await import(serverBundlePath);
Error 7: ERR_UNSUPPORTED_ESM_URL_SCHEME (Windows)
Error [ERR_UNSUPPORTED_ESM_URL_SCHEME]: Only file and data URLs are supported
by the default ESM loader. Received protocol 'c:'
Root cause: On Windows, file paths starting with C:\ are not valid ESM URLs. Dynamic import() requires a file:// URL.
Fix: Use pathToFileURL to convert the path:
import { pathToFileURL } from 'node:url';
const serverBundlePath = pathToFileURL(join(serverDistFolder, 'main.server.mjs')).href;
// Results in: file:///C:/Users/.../dist/web/server/main.server.mjs
Error 8: TypeScript composite error
error TS6306: Referenced project '...tsconfig.app.json' must have setting "composite": true.
Root cause: Angular 21 uses TypeScript project references, which require "composite": true in all referenced tsconfig files.
Fix: Add "composite": true to compilerOptions in tsconfig.app.json, tsconfig.server.json, and tsconfig.spec.json.
Common Pitfalls
Pitfall 1: Running build:ssr:dev Once But Forgetting to Rebuild After Code Changes
Unlike ng serve, thereβs no hot reload in this setup. After any code change:
npm run build:ssr:dev
# Then refresh the browser
Or to save time, rebuild without restarting the server:
npm run build:ssr:dev
# (The server still needs a manual restart if server.ts changed)
Pitfall 2: Port Conflicts
Error: listen EADDRINUSE: address already in use :::8201
# Windows
netstat -ano | findstr :8201
taskkill /PID <PID> /F
# Mac/Linux
lsof -ti:8201 | xargs kill -9
Or change the port:
cross-env PORT=4200 ts-node --esm server.ts
Pitfall 3: Styles Donβt Load After Copy-Index Copies the Wrong File
Symptom: Page renders with content but completely unstyled.
Cause: copy-index.js found src/index.html (which has no bundled <link> tags) instead of dist/web/browser/index.csr.html (which has references to compiled CSS).
Fix: Verify copy-index.js logs show it found index.csr.html, not a fallback path. Also check that ng build ran successfully before the copy script.
Pitfall 4: Old Code Still Running After Rebuild
Symptom: You changed code but donβt see the changes even after rebuilding.
Causes and fixes:
- Browser cache: Hard refresh with
Ctrl+Shift+R(Windows/Linux) orCmd+Shift+R(Mac) - Server didnβt reload:
Ctrl+Cthennpm run start:ssr:devagain - Build didnβt run: Ensure
ng buildsucceeded β look for theBrowser application bundle generation completemessage in build output
Pitfall 5: Wrong Environment in Production Build
Symptom: After running npm run build:ssr, API calls go to /api instead of your production URL.
Cause: Used --configuration development when you meant production, or fileReplacements is not configured in angular.json.
Fix: Verify angular.json has fileReplacements in the production configuration pointing to environment.prod.ts.
Pitfall 6: API Proxy Not Working
Symptom: 404 or connection refused on /api/* requests.
Diagnosis:
- Is backend running?
curl http://localhost:3000/api/health - Are
[PROXY]log lines appearing in the Express terminal? - Is the environment configured with
apiUrl: '/api'?
Cause note: proxy.conf.json is only read by ng serve (Angularβs dev server). Your custom Express server ignores it entirely β thatβs why server.ts implements the proxy manually using Nodeβs http module.
Troubleshooting
Issue: Server Crashes Immediately on Startup
Symptoms: Express starts then crashes before printing the listen message.
Diagnosis:
ts-node --esm server.ts 2>&1 | head -50
Common causes:
main.server.mjsnot found (build didnβt run or bundle path is wrong)index.server.htmlnot found (copy-index.js didnβt run)- Import errors in
server.ts - Missing npm packages (
npm install)
Issue: <app-root> Is Empty (SSR Not Rendering)
Symptoms: Page loads but curl http://localhost:8201 | grep app-root shows <app-root></app-root> with no content.
Diagnosis: Check Express terminal output for SSR rendering error: messages.
Common causes:
provideServerRenderingfrom wrong package (causesNG0201which gets caught by.catch(next))- Browser-specific code running during SSR (use
isPlatformBrowserguards) - Missing
documentFilePathβindex.server.htmldoesnβt exist
Fix for platform-specific code:
import { isPlatformBrowser } from '@angular/common';
import { inject, PLATFORM_ID } from '@angular/core';
export class MyComponent {
platformId = inject(PLATFORM_ID);
ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
// Browser-only code (localStorage, window, document, etc.)
}
}
}
π Angular docs β isPlatformBrowser: Use this to guard browser-only APIs from running during server-side rendering.
Issue: Authentication Doesnβt Work
Symptoms: Always redirected to login even after successful auth, or auth state lost on page refresh.
Diagnosis:
- Check Network tab β login response β
Set-Cookieheader present? - Check subsequent requests β
Cookieheader sent? - Check SSR: auth check running server-side when it shouldnβt?
Fix: Skip auth initialization during SSR:
if (!isPlatformBrowser(platformId)) {
return Promise.resolve();
}
Issue: Build Succeeds But Angular Version Mismatch
Symptoms: Build succeeds but server throws version-related errors.
Fix: Ensure all Angular packages are on the same version:
ng version
# All @angular/* packages should be 21.x.x
If theyβre mismatched, run:
ng update @angular/core @angular/cli @angular/ssr
Quick Reference
Start SSR Dev Server (Full Build + Run)
cd apps/web
npm run start:ssr:dev
# Opens on http://localhost:8201
Rebuild Only (Without Restarting Server)
npm run build:ssr:dev
# Then hard-refresh browser (Ctrl+Shift+R)
Verify SSR Is Rendering
curl http://localhost:8201 | grep "<app-root"
# Should show content inside <app-root>, not empty tags
Verify Environment
grep "apiUrl" dist/web/browser/main.js
# Should show: apiUrl:"/api"
Test API Proxy
curl http://localhost:8201/api/health
# Should forward to backend and return response
Clear Everything and Rebuild
rmdir /s /q dist
npm run build:ssr:dev
Verify Server Bundle Exists
dir dist\web\server\main.server.mjs
# If missing, build didn't produce server bundle β check angular.json has ssr: true
Success Checklist
-
cross-envandts-nodeinstalled as dev dependencies -
angular.jsonuses@angular/build:applicationbuilder (no separateservertarget) -
angular.jsonhas"server": "src/main.server.ts"and"ssr": truein bothdevelopmentandproductionconfigurations -
tsconfig.app.jsonhas"composite": true -
tsconfig.app.jsonlistssrc/main.server.tsandsrc/app/app.config.server.tsinfiles -
tsconfig.server.jsonhas"composite": true -
tsconfig.spec.jsonhas"composite": true -
src/main.server.tsexports a default function acceptingBootstrapContext -
app.config.server.tsimportsprovideServerRenderingfrom@angular/ssr(not@angular/platform-server) -
server.tsimports@angular/compilerbefore any other Angular imports -
server.tsloads fromdist/web/server/main.server.mjs(notmain.js, notsrc/) -
server.tsusespathToFileURL()for the bundle import path (Windows ESM requirement) -
server.tsusesCommonEngine(notAngularNodeAppEngine, notrenderApplication) -
server.tsusesbootstrap = serverModule.default(single.default, not nested) -
server.tsusesdocumentFilePath:incommonEngine.render()(notdocument:) -
server.tsusesproviders:incommonEngine.render()(notplatformProviders:) -
scripts/copy-index.jshandlesindex.csr.htmlfilename (not onlyindex.html) -
package.jsonbuild:ssr:devusesng build --configuration development(no separate server build step) -
environment.development.tshasapiUrl: '/api' - Backend running on
localhost:3000 -
npm run start:ssr:devstarts server onhttp://localhost:8201 - Page source shows rendered HTML in
<app-root>(not empty tags) - Styles load correctly
-
[PROXY]log lines appear for API requests - Authentication works without login flash
Complete Summary
What We Were Trying to Do
Run true server-side rendering locally during development β meaning every page request is rendered on the server before being sent to the browser. This mirrors production behavior, which is essential for testing SSR-specific features like meta tags, server routes, and authentication flows that depend on the initial server response.
In Angular 20, this was achieved with a custom Express server that loaded the @angular-devkit/build-angular:server output (main.js), called renderApplication() from that bundle, and proxied API requests to the local backend. That setup worked reliably.
What Changed When Migrating to the Modern SSR Setup
The modern @angular/build:application builder (introduced in Angular 17) replaced the dual-builder SSR architecture. The change was well-motivated β one build, one configuration, one tool β but it requires updates to existing local dev setups:
-
The
servertarget inangular.jsonwas removed. The unified builder handles both browser and server bundles when"ssr": trueis set in the configuration. -
The server bundle changed format. From CommonJS
main.jsto ESMmain.server.mjs. This requirespathToFileURL()for dynamic import on Windows, and means the bundle no longer exportsrenderApplication. -
The index HTML was renamed. From
index.htmltoindex.csr.html. Any script that copies the index template must be updated to look for the new name. -
provideServerRenderingmoved packages (Angular 20). From@angular/platform-serverto@angular/ssr. This change happened in Angular 20, not Angular 21. Using the old import path causesNG0201: No provider found for PlatformDestroyListenersβ an error thatβs completely opaque about its actual cause. -
The new recommended API,
AngularNodeAppEngine, doesnβt work for this pattern. It reads a manifest file that canβt be located when runningts-node server.tsfrom the project root. Itβs the right tool for production, but not for local dev with a custom Express server. -
TypeScript project references are now required. All three tsconfig files need
"composite": true. The unified builder also requires server entry files (main.server.ts,app.config.server.ts) to be listed intsconfig.app.jsonβsfilesarray.
What the Working Solution Looks Like
The key insight was that CommonEngine from @angular/ssr/node is the stable, explicitly-controlled path for custom Node.js servers in Angular 21. Unlike AngularNodeAppEngine, it doesnβt rely on manifest-based auto-discovery. You load your bootstrap function from the compiled .mjs bundle, pass it to commonEngine.render() along with explicit paths for the document and public files, and youβre done.
The complete working setup in Angular 21:
| Component | Older Setup | Recommended Setup |
|---|---|---|
| Builder | Two builders (application + server) | One builder (@angular/build:application) |
| Build command | ng build + ng run :server:dev | ng build --configuration development |
| Server bundle | main.js (CommonJS) | main.server.mjs (ESM) |
| Index HTML | index.html | index.csr.html |
| Rendering API | renderApplication() from bundle | CommonEngine from @angular/ssr/node |
provideServerRendering | From @angular/platform-server | From @angular/ssr |
| Bootstrap extraction | serverModule.default.default | serverModule.default |
tsconfig composite | Not needed | Required in all three tsconfig files |
Server files in tsconfig.app.json | Not needed | Required in files array |
The Errors and Their Root Causes
Every error encountered during this upgrade had a specific root cause:
- NG0201 PlatformDestroyListeners β wrong
provideServerRenderingpackage - renderApplication is not a function β the unified builder no longer exports it from the server bundleβ
- Cannot find module main.server.js β filename changed to
.mjs - Angular app engine manifest is not set β
AngularNodeAppEnginecanβt find manifest from project root - JIT compilation failed β
@angular/compilerimport missing or not first - Cannot find module src/main.server.js β importing from source instead of compiled bundle
- ERR_UNSUPPORTED_ESM_URL_SCHEME β Windows requires
pathToFileURL()for ESM dynamic imports - TypeScript composite error β the unified builder requires
"composite": truein all referenced tsconfigs
What Didnβt Change
Despite all the above, several things are identical between Angular 20 and 21:
- The API proxy implementation using Nodeβs
http.request()βproxy.conf.jsonis still ignored by custom Express servers in both versions - The
BootstrapContextparameter inmain.server.tsβ required in both - The
fileReplacementsapproach for environment-specific builds β unchanged - Static file serving and caching strategy β unchanged
- The overall Express server structure β same middleware ordering, same route handler pattern
Next Steps
With local SSR working on Angular 21:
- Test SSR-specific features (meta tags, Open Graph, server routes, canonical URLs)
- Debug SSR hydration issues β check for
ExpressionChangedAfterItHasBeenCheckedErrorafter hydration - Test authentication flows β verify httpOnly cookies work correctly server-side
- Prepare for AngularNodeAppEngine β once Angular tooling matures, this will be the cleaner path for production
Additional Resources
- Angular SSR Guide β Official documentation for Angular 21 SSR setup
- CommonEngine API β The rendering engine used in this guide
- AngularNodeAppEngine API β The future of Angular Node.js SSR
- provideServerRendering API β The
@angular/ssrimport - BootstrapContext API β Required for SSR bootstrap
- isPlatformBrowser β Guard browser-only code
- Part 1: Angular 20 SSR Local Dev β The Angular 20 version of this guide
Questions or feedback? Reach out via contact form or @stackinsightDev
This guide reflects the actual working implementation used during the Angular 20 β 21 upgrade. All configurations tested with Angular 21.1.5, Node 20, and Express 4.