Deploying SSR Apps
Server-side rendering (SSR) lets Angular render your pages to HTML on the server before shipping them to the browser, which improves first-paint performance, SEO, and link previews. Unlike a static build, an SSR app produces a live Node application that must run somewhere — a long-lived server, a container, or a serverless function. This page covers building the SSR bundle and the realistic ways to deploy it, from a plain Node host to serverless and edge platforms.
How an Angular SSR build is structured
Modern Angular (17+) ships SSR through the application builder and @angular/ssr. When you scaffold or add SSR, the CLI wires up an Express server that hands incoming requests to Angular’s CommonEngine (or the newer AngularNodeAppEngine).
Add SSR to an existing project:
ng add @angular/ssr
This generates src/server.ts (the Node entry point), updates angular.json with a server target, and enables prerendering. The build emits a dist/<project>/ folder split into browser/ (client assets) and server/ (the rendering bundle plus server.mjs).
ng build
Output:
Initial chunk files | Names | Raw size
main.js | main | 142.18 kB
...
Application bundle generation complete. [8.412 seconds]
Output location: /app/dist/storefront
Prerendered 3 static routes.
A typical src/server.ts looks like this:
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 }));
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 server listening on ${port}`));
}
export const reqHandler = createNodeRequestHandler(app);
The exported
reqHandleris the key to serverless: it adapts the Express app into a request handler that platform functions can call directly, without binding to a port.
Deploying to a Node server
The most direct option is any host that runs Node 20+ — a VM, a managed Node platform (Render, Railway, Fly.io, Heroku), or your own box behind Nginx. Build, then run the generated server entry:
ng build
node dist/storefront/server/server.mjs
Bind to the platform’s injected PORT (already handled above) and put a process manager or the platform’s supervisor in front so the app restarts on crashes. A minimal production start script:
PORT=8080 NODE_ENV=production node dist/storefront/server/server.mjs
For self-managed servers, run it under PM2 and reverse-proxy TLS through Nginx:
pm2 start dist/storefront/server/server.mjs --name storefront -i max
The -i max flag forks one instance per CPU core, since a single Node process is single-threaded and SSR is CPU-bound.
Deploying to serverless platforms
Serverless removes the always-on server: the platform spins your handler up per request (or keeps warm instances). Because server.ts exports reqHandler, you wrap it in the platform’s adapter.
For Firebase Cloud Functions or Cloud Run, point the function at the exported handler:
import { reqHandler } from './dist/storefront/server/server.mjs';
export const ssr = reqHandler;
For Netlify and Vercel, dedicated Angular adapters detect the SSR output automatically — you generally only set the build command (ng build) and the output directory (dist/storefront), and the platform bundles the server into a function. The table below compares the common targets.
| Platform | Model | Cold starts | Best for |
|---|---|---|---|
| Render / Railway | Long-lived Node | None | Steady traffic, simplest mental model |
| Cloud Run | Container, autoscaled | Low | Bursty traffic, scale-to-zero |
| Firebase Functions | Serverless function | Medium | Tight Firebase/Firestore integration |
| Netlify / Vercel | Managed function | Low–medium | JAMstack + SSR mix, zero-config |
| Cloudflare Workers | Edge runtime | Minimal | Global low-latency SSR |
Edge deployment
Edge platforms (Cloudflare Workers, Netlify/Vercel Edge) run your handler in a lightweight V8 runtime physically close to users, cutting latency dramatically. The catch is the runtime is not full Node — there’s no fs, no native express, and a Web-standard fetch/Request/Response API instead.
Angular supports this through the Web-API engine rather than the Node one:
import { AngularAppEngine, createRequestHandler } from '@angular/ssr';
const angularApp = new AngularAppEngine();
export const reqHandler = createRequestHandler(async (request: Request) => {
const result = await angularApp.handle(request);
return result ?? new Response('Not found', { status: 404 });
});
Edge runtimes forbid Node-only APIs. Avoid
process,Buffer, file-system reads, and native modules in code that runs during SSR, or the deploy will fail at runtime. Guard server-only logic and keep heavy dependencies out of the render path.
Hybrid rendering and routes
You rarely want pure SSR for every page. Angular’s server route config lets you choose per route: prerender truly static pages at build time, SSR dynamic ones, and leave client-only pages to CSR.
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'product/:id', renderMode: RenderMode.Server },
{ path: 'account/**', renderMode: RenderMode.Client },
];
This keeps your serverless function lean: only the routes that genuinely need a server hit it, and everything else is served as static files from the CDN.
Best Practices
- Target Node 20+ and set
NODE_ENV=productionso Express and Angular skip development overhead. - Serve
browser/assets from a CDN or with longmaxAgecache headers; never let the SSR process serve hashed static files on every request. - Use per-route render modes (prerender / server / client) so only genuinely dynamic pages invoke the server runtime.
- On serverless, deploy the exported
reqHandlerrather than callingapp.listen()— binding a port wastes cold-start time and may not work at all. - For edge targets, use
AngularAppEngine(Web API) and audit dependencies for Node-only APIs before shipping. - Run multiple instances (PM2 cluster mode or platform autoscaling) because SSR is CPU-bound and a single Node process won’t saturate a multi-core host.
- Set a sensible request timeout and add a health-check endpoint so your platform can recycle stuck render workers.