Angular 20 Migration: SSR Challenges & Resolutions with Vite, ESM, and Express 5

Angular Feb 20, 2026

At Halodoc, our public website halodoc.com has long relied on Angular with Server-Side Rendering (SSR) — powered by a custom Express server — to deliver fast, SEO-friendly pages to millions of users. When Angular 20 arrived, it brought a wave of architectural changes that touched nearly every layer of our stack: the build tooling, the module system, the server runtime, and even how routes are extracted at compile time.

This blog is not a generic upgrade checklist. It is a real-world blueprint drawn directly from the challenges we hit, the errors we debugged, and the resolutions we shipped — so that the next team migrating a complex SSR monolith doesn't have to start from scratch.

At a Glance — What Changed & What Broke

Here's the summary of what changed and what we had to fix across the stack:

Challenge Area What Changed Key Fix
Build System Webpack → Vite + esbuild New @angular/build:application builder
Module Format CommonJS → ESM (.mjs) PM2, Docker, server.ts all need ESM
SSR Engine CommonEngine → AngularNodeAppEngine New handle() + context bootstrap pattern
Express Version Express 4 → Express 5 Route syntax rewrite, async middleware
RxJS Version 6.x → 7.8+ ESM-compatible exports map required
Package Exports Deep imports allowed Strict exports enforcement — use public API
Dev Server Flags --disable-host-check removed Vite security model replaces webpack flags
TypeScript Lenient 3rd-party types Strict checks — custom .d.ts files needed

Architecture Overview — Before & After

Before Angular 20, our SSR pipeline looked like this: a Webpack-bundled Angular app producing dist/server/main.js (CommonJS), consumed by an Express 4 server using CommonEngine to render HTML. PM2 managed the process in cluster mode.

After Angular 20, the entire output and runtime contract changed. Here are both architectures side-by-side:

Before Migration — Angular 17/19 SSR Architecture

Figure 1: Before Angular 20 SSR architecture

Figure 1: Angular 17/19 SSR architecture using Webpack, CommonJS, Express 4, and CommonEngine render() API.

After Migration — Angular 20 SSR Architecture

Figure 2: Angular 20 SSR Architecture

Figure 2: Updated Angular 20 SSR architecture using Express 5, AngularNodeAppEngine, and an ESM server entrypoint with per-request context.

Key shift: The server entry point is now an ESM .mjs file, consumed by Express 5 via AngularNodeAppEngine, with per-request context injection.

Build Performance Gains

The migration to esbuild brought measurable improvements to our CI/CD pipeline:

  • Production builds: Reduced from ~8 minutes (Webpack) to ~3.5 minutes (esbuild) — a 56% improvement
  • Development startup: Cold start went from ~45 seconds to ~12 seconds with Vite's on-demand compilation
  • Incremental rebuilds: Hot Module Replacement (HMR) now completes in < 200ms vs. 2-4 seconds previously

1. Build System Migration — Webpack → Vite + esbuild

💡
Challenge: Angular 20 dropped the Webpack-based builder entirely. The new @angular/build:application uses Vite for dev and esbuild for production — changing output paths, module formats, and dev-server flags.

What exactly changed?

  • Dev server now runs on Vite instead of webpack-dev-server — different HMR behavior, faster cold starts, but incompatible flags like --disable-host-check were removed
  • Bundle output: dist/server/main.js (CJS) became dist/<app>/server/server.mjs (ESM)
  • Dependency resolution: esbuild resolves all imports eagerly — even conditional or dynamic ones
  • HMR (Hot Module Replacement) mechanism is now Vite-native.
💡
Resolution: Migrated angular.json to use @angular/build:application. Removed all webpack-specific flags. Updated every build script to point to the new output path. Fully embraced ESM.

angular.json — Builder Configuration

{
  "projects": {
    "halodoc": {
      "architect": {
        "build": {
          "builder": "@angular/build:application",   // ← new builder
          "options": {
            "outputPath": { "base": "dist" },
            "browser": "src/main.ts",
            "externalDependencies": ["canvas", "path2d"]  // ← esbuild exclusions
          }
        }
      }
    }
  }
}

A

2. SSR Configuration — The Missing Metadata Problem

💡
Challenge: After running ng update, the angular.json lost all SSR-specific blocks. No ssr entry, no server bootstrap, no outputMode. The app compiled — but never actually served SSR. This is a subtle failure mode: builds are green, but users see CSR-only behaviour and SEO quietly degrades.

What was missing?

  • No ssr block under build options
  • No server entry pointing to main.server.ts
  • No outputMode: "server" declaration
  • No serve-ssr target for local dev
💡
Resolution: Manually added the full SSR configuration for every environment: staging, production, and development-ssr. The key insight — Vite cannot build the Angular server bundle yet, so dev SSR must use a separate build + serve step.

Complete SSR Configuration (angular.json)

"configurations": {
  "production": {
    "server": "src/main.server.ts",       // Angular server bootstrap
    "ssr": {
      "entry": "src/server.ts"            // Express server entry
    },
    "outputMode": "server",               // SSR mode (not static prerender)
    "prerender": {
      "discoverRoutes": false             // manual route control
    }
  },
  "development-ssr": {
    "server": "src/main.server.ts",
    "ssr": { "entry": "src/server.ts" },
    "outputMode": "server",
    "optimization": false,
    "sourceMap": true
  }
}

SSR configuration in ngular application builder

Key Configuration Pointers

  • server → Angular's server bootstrap (main.server.ts)
  • ssr.entry → Your Express server file (server.ts)
  • outputMode: "server" → Enables live SSR rendering
  • prerender.discoverRoutes: false → Disables auto route scanning

3. CommonJS → ESM — The Ripple Effect

💡
Challenge: esbuild outputs .mjs files by default. But our Docker configs, PM2 ecosystem file, and server.ts all assumed CommonJS. Everything broke at startup.

Before vs After — Server Entry Point

Before After
// server.ts (CommonJS)
const express = require('express');

const { CommonEngine } =
  require('@angular/ssr/node');

// PM2 script path:
'./dist/server/main.js'
// server.ts (ESM)
import express from 'express';

import {
  AngularNodeAppEngine,
  createNodeRequestHandler
} from '@angular/ssr/node';

// PM2 script path:
'./dist/halodoc/server/server.mjs'
💡
Resolution: Updated PM2 ecosystem config to point to server.mjs. Ensured Node.js 20.19+ (native ESM). Converted server.ts fully to import/export syntax. Updated our Node.js runtime to Node 20+ across CI agents and servers to ensure native ESM support.

PM2 Configuration (ecosystem.prod.config.js)

module.exports = {
  apps: [{
    name: 'public-web-app',
    script: './dist/public-web-app/server/server.mjs',  // ← .mjs, not .js
    interpreter: 'node',
    node_args: '--max-old-space-size=12000',
    instances: 2,
    exec_mode: 'cluster'
  }]
};

PM2 configuration

4. Express 4 → Express 5 — Route Syntax Rewrite

💡
Challenge: Angular 20's ESM-only SSR runtime is incompatible with Express 4 (which is CommonJS-only). Express 5 also changed wildcard route syntax entirely — '*' no longer works as a catch-all.

Route Pattern Changes

Before (Express 4 / Old) After (Express 5 / New)
// Express 4 catch-all
server.get('*', (req, res) => {
  // SSR handler
});

// Wildcard prefix
server.get('/homelab/*', (req, res) => {
  // handler
});
// Express 5 — middleware SSR
server.use(async (req, res, next) => {
  // SSR handler
});

// Named param capture
server.get('/homelab/:captured(*)', (req, res) => {
  res.redirect(
    301,
    '/homecare/' + req.params.captured
  );
});

Key Express 5 Pattern Changes

  • Use /{*path} for catch-all routes instead of *.
  • Use :param(*) for wildcard parameter captures instead of **.
  • Prefer server.use() middleware over catch-all routes for SSR handlers.
  • Top-level await is now supported in middleware functions.

Why it matters: Getting these patterns wrong can silently break redirects or cause 404s only on specific wildcards, so we tested route matching extensively in staging before rollout.

💡
Resolution: Migrated to Express 5.0+ and rewrote all route patterns to match the new syntax. Updated catch-all SSR handler from app.get('*') to app.use() middleware pattern. Tested route matching extensively to ensure redirects and wildcard captures work correctly.

5. Dependency Compatibility — Three Gotchas

Most of our time here went into small, scattered fixes rather than one big blocker—this is where having good logs and TypeScript errors mattered.

💡
 Error: Cannot find name 'JQuerySlickOptions' / 'JQuery.TriggeredEvent'. Root cause: Angular 20 removed automatic inclusion of DOM globals, and ngx-slick-carousel doesn't declare jQuery as a peer dependency.

Resolution: Install @types/jquery explicitly and ensure it is included in tsconfig types, so ngx-slick-carousel can compile correctly under Angular 20's stricter type setup.

5b. Canvas Native Module — esbuild Eagerness

💡
Error: Cannot find module 'canvas' or its corresponding type declarations. Root cause: pdfjs-dist has a conditional import('canvas') for Node SSR helpers. Webpack skipped it; esbuild does not.

Resolution: Tell esbuild to externalize these modules:

// angular.json → build → options
"externalDependencies": ["canvas", "path2d"]
// This tells esbuild to skip bundling these entirely.

5c. RxJS 6.x → 7.8+ — ESM Directory Imports

💡
Error: ERR_UNSUPPORTED_DIR_IMPORT: Directory import 'node_modules/rxjs/operators' is not supported. Root cause: RxJS 6.x uses directory exports which Node's native ESM disallows without an exports map.

Resolution: Upgrade to RxJS 7.8+ (Angular 20's minimum). Additionally, some deprecated operators were removed — audit your catchError, switchMap usage during the upgrade.

6. CommonEngine → AngularNodeAppEngine

💡
 Challenge: The old CommonEngine render() API was removed, and Angular 20’s AngularNodeAppEngine expects a Web-standard Request → Response model instead.

Before vs After — SSR Engine Usage

Before After
// Old: CommonEngine
const engine = new CommonEngine();
engine.render({
  bootstrap,
  documentFilePath: indexHtml,
  url: req.url,
  providers: [...]
}).then(html => res.send(html));
// New: AngularNodeAppEngine
const app = new AngularNodeAppEngine();

const response = await app.handle(
  req,
  {
    providers: [
      { provide: REQUEST, useValue: req },
      { provide: RESPONSE, useValue: res }
    ]
  }
);

// response is a web Response

Note: Because handle() returns a Web Response object, we can now apply cross-cutting concerns (like CSP nonce injection and header manipulation) in one place before sending the response.

main.server.ts — Bootstrap Context

// Angular 20: bootstrap now receives context
const bootstrap = (context?: any) =>
  bootstrapApplication(AppComponent, config, context);

export default bootstrap;

// This is how AngularNodeAppEngine passes
// REQUEST, RESPONSE providers per request.

Bootstrap context in main.server.ts

7. Strict Package Export Enforcement

💡
Challenge: Deep imports like '@halodoc/package/lib/Package/models/...' stopped working. Angular 20 only allows paths declared in the package's exports field.

The Problem


// Breaks in Angular 20
import { MessageComponent }
  from '@halodoc/package/lib/Package/components/message-component';

// Correct way — import from package root
import { MessageComponent } from '@halodoc/Package';

Importing package which are not declared in package export

Fix — Expose via public-api.ts

// libs/package/public-api.ts
export * from './lib/package/components/message-component;

Declaring package via public-api.ts

Then rebuild and republish the library. All consuming apps switch to the root import. This also improves tree-shaking and maintains proper versioning contracts.

8. Route Extraction Errors — Catch Block Strictness

💡
Challenge: Build failed with 'Route extraction failed during build optimization'. Not a route config issue — it was caused by catch blocks that didn't declare a typed error variable.

Before vs After — Error Handling

Before After
//  Causes build failure
try {
  // code
} catch {          // no variable!
  // silent
}

this.http.get('/api').pipe(
  catchError(() => of(null))
);
//  Build succeeds
try {
  // code
} catch (error: Error) {
  console.error(error);
}

this.http.get('/api').pipe(
  catchError((error: Error) => {
    console.error(error);
    return of(null);
  })
);

Rule of Thumb

  • Always declare an error variable in catch
  • Type it as Error or unknown — never omit
  • Use the variable (even just for logging) — the optimizer checks for it

9. CSP Nonce Injection — Security Under SSR

Because our public site is internet-facing and uses a strict Content Security Policy (CSP), preserving nonce-based script tags across the new SSR stack was non-negotiable.

With the new AngularNodeAppEngine returning a Web Response object instead of a raw HTML string, we had to adjust how we inject nonces into <script> tags for Content Security Policy.

Nonce Injection Utility


function injectNonceIntoScripts(html: string, nonce: string): string {
  if (!nonce) return html;
  return html.replace(
    /<script(?![^>]*\snonce=)/gi,
    '<script nonce="' + nonce + '"'
  );
}

// In the SSR middleware:
const response = await angularApp.handle(req, ctx);
if (response?.headers.get('content-type')?.includes('text/html')) {
  const html = await response.text();
  res.status(response.status).send(
    injectNonceIntoScripts(html, res.locals.nonce)
  );
}

Injecting nonce in Server.ts

Verification: We verified nonce injection manually in staging by inspecting rendered HTML and checking that all inline script tags carried the expected nonce and passed our CSP headers.

10. Prometheus Monitoring — Singleton Registry Pattern

💡
Challenge: server.ts gets imported multiple times during SSR warm-up, HMR, and PM2 cluster restarts. Each import re-registered Prometheus metrics, crashing with 'metric already registered'.

The Fix — Global Singleton

// src/server/prometheus.ts
const globalRef: any = globalThis;

if (!globalRef.__PROMETHEUS_REGISTRY__) {
  globalRef.__PROMETHEUS_REGISTRY__ = new Registry();
}

export function setupPrometheus(app: Express) {
  if (globalRef.__PROMETHEUS_INITIALIZED__) return;
  globalRef.__PROMETHEUS_INITIALIZED__ = true;

  app.use(promMid({
    metricsPath: '/metrics_prom',
    registry: globalRef.__PROMETHEUS_REGISTRY__,
    prefix: 'website_',
    collectDefaultMetrics: false
  }));
}

Prometheus registry as global singleton

This pattern is safe to call multiple times — only the first invocation registers metrics. Works with HMR, cluster mode, and SSR re-imports.

11. Missing Type Declarations — Custom .d.ts Files

💡
Challenge: Packages like express-prometheus-middleware and xhr2 have no published types. Angular 20's stricter TypeScript checks made these compile errors.

Resolution — Declare Modules

// src/types/express-prometheus-middleware.d.ts
declare module 'express-prometheus-middleware' {
  const promMid: any;
  export default promMid;
}

// src/types/xhr2.d.ts
declare module 'xhr2' {
  const xhr2: any;
  export default xhr2;
}

Ensure tsconfig.app.json includes "**/*.ts" so these declaration files are picked up. Long-term: seek packages with proper types or contribute to DefinitelyTyped.

12. Removed Dev Server Flags

💡
Error: ng serve --disable-host-check → 'Unknown argument: disable-host-check'. Vite's dev server has a different security model and this flag simply doesn't exist.

Updated package.json Scripts

{
  "scripts": {
    "start":     "ng serve --host 0.0.0.0 --port=4000",
    "dev":       "ng serve --configuration=development --port=4000",
    "dev:ssr":   "ng serve --configuration=development-ssr --port=4000"
  }
}

Scripts for development

Migration Quick-Reference Checklist

Use this as a starting point for your own Angular 20 SSR migration runbook; adjust steps to match your CI/CD and hosting model.

Build & Runtime

  1. Run ng update @angular/core@20 @angular/cli@20 — then do not trust it blindly. Manually verify angular.json.
  2. Add the full SSR block (server, ssr.entry, outputMode) for every environment.
  3. Switch PM2, Docker, and CI scripts to .mjs paths.
  4. Migrate Express 4 → 5. Rewrite route patterns.
  5. Replace CommonEngine with AngularNodeAppEngine.

Dependencies & Types

  1. Upgrade RxJS to 7.8+. Fix removed operators.
  2. Audit all deep package imports. Export via public-api.ts.
  3. Add externalDependencies for native modules (canvas, path2d, etc.).

Security & Dev Experience

  1. Fix every catch block — add a typed error variable.
  2. Remove --disable-host-check from all npm scripts.
  3. Run the full test suite. Check CSP nonce injection manually.

Conclusion

Angular 20's migration is not just a version bump; it is an architectural shift. The move to esbuild, ESM-first module resolution, and the new AngularNodeAppEngine touches the build pipeline, the server runtime, and the deployment config simultaneously.

The good news: once you understand the why behind each change, the fixes are straightforward. This blog is meant to be your shortcut through the debugging we already did. Bookmark the checklist, adapt the code examples to your project, and consider adding similar guardrails (CSP nonces, Prometheus patterns, strict typings) early in your migration instead of treating them as afterthoughts.

References

Join us

Scalability, reliability, and maintainability are the three pillars that govern what we build at Halodoc Tech. We are actively looking for engineers at all levels, and if solving hard problems with challenging requirements is your forte, please reach out to us with your resume at careers.india@halodoc.com.

About Halodoc

Halodoc is the number one all-around healthcare application in Indonesia. Our mission is to simplify and deliver quality healthcare across Indonesia, from Sabang to Merauke.
Since 2016, Halodoc has been improving health literacy in Indonesia by providing user-friendly healthcare communication, education, and information (KIE). In parallel, our ecosystem has expanded to offer a range of services that facilitate convenient access to healthcare, starting with Homecare by Halodoc as a preventive care feature that allows users to conduct health tests privately and securely from the comfort of their homes; My Insurance, which will enable users to access the benefits of cashless outpatient services more seamlessly; Chat with Doctor, which allows users to consult with over 20,000 licensed physicians via chat, video or voice call; and Health Store features that allow users to purchase medicines, supplements and various health products from our network of over 4,900 trusted partner pharmacies. To deliver holistic health solutions in a fully digital way, Halodoc offers Digital Clinic services, including Haloskin, a trusted dermatology care platform guided by experienced dermatologists.
We are proud to be trusted by global and regional investors, including the Bill & Melinda Gates Foundation, Singtel, UOB Ventures, Allianz, GoJek, Astra, Temasek, and many more. With over USD 100 million raised to date, including our recent Series D, our team is committed to building the best personalized healthcare solutions, and we remain steadfast in our journey to simplify healthcare for all Indonesians.

Tags

Tharun S B

Software Development Engineer 3 at Halodoc