Angular 20 SSR Local Development: Testing SSR the Right Way
Angular 20 SSR Local Development: Testing SSR the Right Way
How to actually test SSR locally without lying to yourself about what’s working
Last updated: November 2025 | Angular 20.2+ | Node.js 20
The Local Development Dilemma
The promise: Run ng serve and develop your Angular SSR app locally with hot reload and instant feedback.
The harsh reality: ng serve doesn’t run true SSR. It’s a dev server that compiles in-memory and serves your app client-side. So you build features, test them locally, deploy to Netlify, and discover:
- Your httpOnly cookie authentication doesn’t work
- Platform-specific code crashes the server
- Meta tags that looked fine locally aren’t being set
- The whole “SSR” thing you thought you tested? Wasn’t actually running.
I learned this the hard way after spending a weekend debugging “SSR issues” that only appeared in production. Turns out, I was never testing SSR locally.
Here’s the thing: if you want to develop with SSR, you need to actually RUN SSR locally. Not simulate it. Not assume it works. Actually execute your Angular app on a Node.js server and see what breaks.
This guide shows you how to set up a local development environment that mirrors your production SSR deployment—so when you test locally, you’re testing the same code path that runs on Netlify.
What You’re Actually Building
By the end of this guide, you’ll have:
- ✅ True server-side rendering running locally (not
ng servemagic) - ✅ Express server with API proxy forwarding requests to your backend
- ✅ Development environment configuration that swaps in local API URLs
- ✅ Disk-based builds you can inspect and debug
- ✅ Platform-aware authentication that works the same locally and in production
- ✅ Understanding of what’s actually happening when your code runs
The Three Development Modes (And When to Use Each)
You’ll end up with three different ways to run your app locally. Here’s when to use each:
┌─────────────────────────────────────────────────────────────────────┐
│ MODE 1: Standard Dev Server (npm start) │
├─────────────────────────────────────────────────────────────────────┤
│ Command: ng serve │
│ Runs: Angular dev server (in-memory compilation) │
│ SSR: ❌ No (client-side only) │
│ Speed: ⚡ Fast (hot reload, instant) │
│ Use for: UI work, styling, component development │
│ When: "I'm tweaking a button and want instant feedback" │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ MODE 2: Local SSR Dev (npm run start:ssr:dev) ⭐ THIS GUIDE │
├─────────────────────────────────────────────────────────────────────┤
│ Command: npm run start:ssr:dev │
│ Runs: Disk build → Express server → API proxy │
│ SSR: ✅ Yes (true server-side rendering) │
│ Speed: 🐢 Slower (~15 second rebuilds) │
│ Use for: Testing SSR, auth flows, server-side logic │
│ When: "I need to verify this works in production" │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ MODE 3: Production Build (npm run build:ssr) │
├─────────────────────────────────────────────────────────────────────┤
│ Command: npm run build:ssr │
│ Runs: Optimized production build (minified, tree-shaken) │
│ SSR: ✅ Yes (production bundles) │
│ Speed: 🐌 Slowest (~20+ second builds) │
│ Use for: Pre-deployment verification, final testing │
│ When: "About to deploy, let me check everything one last │
│ time with the exact code that will run on Netlify" │
└─────────────────────────────────────────────────────────────────────┘
The key insight: Don’t use Mode 1 to test SSR features. Use Mode 2. That’s what this guide sets up.
The workflow:
- Day-to-day UI work → Mode 1 (
npm start) - Testing SSR features → Mode 2 (
npm run start:ssr:dev) - Before deploying → Mode 3 (
npm run build:ssr) + manual testing
Why ng serve Can’t Test SSR
Let’s be clear about what ng serve does:
- Compiles your Angular app in-memory (nothing written to disk)
- Serves it from a dev server at
localhost:4200 - Watches for file changes and hot-reloads
- Runs entirely client-side (everything renders in the browser)
What it doesn’t do:
- Execute your code on a Node.js server
- Render components to HTML on the server
- Test httpOnly cookie authentication (cookies work differently in-browser vs server)
- Validate platform-specific code (browser vs server)
- Simulate the Netlify serverless environment
The result: Features that “work” in ng serve fail in production SSR.
Our Solution: Real SSR Locally
We’re going to:
- Build your Angular app to disk (browser and server bundles)
- Run an Express server that loads the server bundle
- Configure an API proxy (so
/apigoes to your local backend) - Use development environment configs (local API URLs, not production)
- Test the EXACT code path that runs on Netlify
Yes, it’s slower than ng serve. But it’s honest. And honesty in development saves you hours of production debugging.
Let’s build it.
Prerequisites
Required Tools
node --version # v20.x or higher
ng version # Angular CLI 20.x
npm --version # 10.x or higher
Backend Setup
- Backend API running on
localhost:4000 - Endpoints available at
http://localhost:4000/api/*
Install Dependencies
cd frontend
npm install
# Required for SSR local dev
npm install --save-dev cross-env ts-node
Understanding Angular SSR Builders
Evolution of Angular SSR
Angular Universal (Legacy)
@angular-devkit/build-angular:serverbuilder- NgModule-based
- CommonJS output (
.js) CommonEnginewithrenderModule()
Angular 17-20 (Modern)
@angular-devkit/build-angular:applicationbuilder- Standalone components
- ESM output (
.mjs) renderApplication()API
Our Hybrid Approach
We use both builders for maximum reliability:
Production (Netlify):
- Modern
applicationbuilder with"ssr": true - Generates
main.server.mjs(ESM) - Optimized bundles
Development (Local):
- Deprecated
serverbuilder - Generates
main.js(CommonJS) - Reliable disk builds
- Easier debugging
Why? The modern builder’s dev mode doesn’t always generate reliable server bundles in Angular 20.2.2. The deprecated builder works consistently.
Build Output Structure
After npm run build:ssr:dev:
dist/frontend/
├── browser/
│ ├── index.html # Built with injected <link> and <script>
│ ├── index.csr.html # CSR fallback copy
│ ├── main.js # App bundle
│ ├── styles.css # Compiled styles
│ └── assets/
└── server/
├── main.js # CommonJS server bundle
├── index.server.html # Copy of browser/index.html
└── *.js # Lazy-loaded chunks
Project Structure
frontend/
├── src/
│ ├── main.ts # Browser entry point
│ ├── main.server.ts # Server entry point
│ ├── app/
│ │ ├── app.config.ts # Client config with APP_INITIALIZER
│ │ └── app.config.server.ts # Server config
│ └── environments/
│ ├── environment.ts # Production config
│ └── environment.development.ts # Development config
├── server.ts # Express SSR server
├── angular.json # Build configuration
├── tsconfig.json # Base TypeScript config
├── tsconfig.app.json # Client TypeScript config
├── tsconfig.server.json # Server TypeScript config
├── package.json # Scripts and dependencies
└── scripts/
└── copy-index.js # Post-build script
Configuration Files
angular.json Configuration
{
"projects": {
"frontend": {
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/frontend",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": ["src/assets"],
"styles": ["src/styles.scss"],
"scripts": []
},
"configurations": {
"production": {
"server": "src/main.server.ts",
"ssr": true,
"optimization": true,
"outputHashing": "all"
},
"development": {
"optimization": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}
},
"defaultConfiguration": "production"
},
"server": {
"builder": "@angular-devkit/build-angular:server",
"options": {
"outputPath": "dist/frontend/server",
"main": "src/main.server.ts",
"tsConfig": "tsconfig.server.json"
},
"configurations": {
"development": {
"optimization": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}
},
"defaultConfiguration": "production"
}
}
}
}
}
Key Points:
fileReplacementsin development config swaps environment files- Separate
servertarget ensures reliable CommonJS bundles mainpoints tosrc/main.server.ts, NOTserver.ts
TypeScript Configurations
tsconfig.server.json - Angular server code only:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/server",
"target": "ES2022",
"module": "ESNext",
"types": ["node"]
},
"files": ["src/main.server.ts"],
"exclude": ["server.ts"]
}
tsconfig.app.json - Client code only:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"],
"exclude": ["server.ts", "src/**/*.spec.ts"]
}
Critical: server.ts must be excluded from both configs. It’s Express code, not Angular code.
Environment Setup (The Secret Sauce for Local vs. Production)
Here’s a common mistake: hardcoding API URLs. You end up with code like this scattered everywhere:
// DON'T DO THIS
const apiUrl = 'https://api.yourdomain.app/api';
Then when you want to test locally, you comment it out and add:
// const apiUrl = 'https://api.yourdomain.app/api'; // Prod
const apiUrl = '/api'; // Local
And you inevitably commit the commented-out version to prod. We’ve all been there.
The right way: Environment files that Angular swaps automatically based on your build configuration.
Production Environment (src/environments/environment.ts)
export const environment = {
production: true,
apiUrl: 'https://api.yourdomain.app/api', // Your actual backend
turnstileSiteKey: 'your-turnstile-site-key' // Production key
};
Development Environment (src/environments/environment.development.ts)
export const environment = {
production: false,
ssr: false, // Optional flag for dev-specific logic
apiUrl: '/api', // This gets proxied to localhost:4000
turnstileSiteKey: 'your-turnstile-site-key' // Same or test key
};
How the Magic Works
When you run ng build --configuration development:
- Angular looks at
angular.jsonand finds thedevelopmentconfiguration - It sees
fileReplacementsthat says: “Replaceenvironment.tswithenvironment.development.ts” - During the build, anywhere you import from
environment.ts, you actually getenvironment.development.ts - The final bundle has
/apias the apiUrl (local)
When you run ng build --configuration production:
- No
fileReplacementshappens (production is the default) - You get the actual
environment.tsfile - The final bundle has
https://api.yourdomain.app/api(production)
The beauty: Your service code stays the same:
import { environment } from '../../environments/environment';
@Injectable()
export class AuthService {
private base = environment.apiUrl + '/auth';
// In dev build: '/api/auth' → Proxied to localhost:4000/api/auth
// In prod build: 'https://api.yourdomain.app/api/auth' → Direct call
}
No if statements. No comments to toggle. No accidentally deploying local URLs to production.
Pro tip: Check what actually got built:
# After building in dev mode
grep \"apiUrl\" dist/frontend/browser/main.js
# Should show: apiUrl:\"/api\"
# After building in production mode
grep \"apiUrl\" dist/frontend/browser/main.js
# Should show: apiUrl:\"https://api.yourdomain.app/api\"
Express Server Setup
Server Entry Point (src/main.server.ts)
import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server';
const bootstrap = (context: BootstrapContext) =>
bootstrapApplication(AppComponent, config, context);
export default bootstrap;
Key: Exports default bootstrap function that takes BootstrapContext.
Server Config (src/app/app.config.server.ts)
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering()
]
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
Express Wrapper (server.ts)
This is the heart of local SSR development:
import 'zone.js/node';
import '@angular/compiler'; // CRITICAL: Must be first!
import { APP_BASE_HREF } from '@angular/common';
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/frontend');
const browserDistFolder = join(distFolder, 'browser');
const serverDistFolder = join(distFolder, 'server');
const indexHtml = join(browserDistFolder, 'index.html');
// Import server bundle (CommonJS)
const serverBundlePath = pathToFileURL(join(serverDistFolder, 'main.js')).href;
const serverModule = await import(serverBundlePath);
// Extract renderApplication and bootstrap
const { renderApplication } = serverModule.default;
const bootstrap = serverModule.default.default;
server.set('view engine', 'html');
server.set('views', browserDistFolder);
// Proxy /api requests to backend server (localhost:4000)
server.use('/api', (req: Request, res: Response) => {
const targetPath = `/api${req.url}`;
console.log(`[PROXY] ${req.method} ${targetPath} → http://localhost:4000`);
const options = {
hostname: 'localhost',
port: 4000,
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);
});
// Health check
server.get('/health', (req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Serve static files (NO caching in 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 for all other routes
server.get('*', async (req: Request, res: Response, next: NextFunction) => {
try {
const { protocol, originalUrl, baseUrl, headers } = req;
const url = `${protocol}://${headers.host}${originalUrl}`;
// Read index.html template
const document = await import('fs').then(fs =>
fs.promises.readFile(indexHtml, 'utf-8')
);
// Render using the server bundle's renderApplication
const html = await renderApplication(bootstrap, {
document,
url,
platformProviders: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
});
res.send(html);
} catch (err: unknown) {
console.error('SSR rendering error:', err);
next(err);
}
});
return server;
}
async function run(): Promise<void> {
const port = process.env['PORT'] || 4200;
const server = await app();
server.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
// Only run if not in serverless environment
if (!process.env.NETLIFY && !process.env.VERCEL) {
run().catch(err => {
console.error('Failed to start server:', err);
process.exit(1);
});
}
Critical Details:
- Import order:
@angular/compilermust be imported before any Angular code (prevents JIT compilation errors) - Path conversion: Use
pathToFileURL()for Windows compatibility - Nested exports: Bootstrap function is at
serverModule.default.default - renderApplication: Use the function from server bundle (not
CommonEngine) - No caching: Disabled to prevent stale bundle issues during development
- API proxy: Native Node.js HTTP proxy (Angular’s proxy.conf.json doesn’t apply to custom servers)
Build Scripts
Package.json Scripts
{
"scripts": {
"comment1": "=== LOCAL CSR (no SSR) ===",
"start": "ng serve --configuration development --proxy-config proxy.conf.json",
"comment2": "=== LOCAL SSR ===",
"start:ssr:dev": "npm run build:ssr:dev && cross-env PORT=4200 ts-node --esm server.ts",
"build:ssr:dev": "ng build --configuration development && ng run frontend:server:development && node scripts/copy-index.js",
"comment3": "=== Netlify SSR ===",
"build:ssr": "ng build --configuration production && ng run frontend:server:production && node scripts/copy-index.js && node scripts/stage-ssr-assets.mjs"
}
}
Build Flow:
npm run start:ssr:dev executes:
ng build --configuration development→ Browser bundles with dev environmentng run frontend:server:development→ Server bundle with dev environmentnode scripts/copy-index.js→ Copy built index.html to server foldercross-env PORT=4200 ts-node --esm server.ts→ Start Express server on port 4200
Copy Index Script (scripts/copy-index.js)
const fs = require('fs');
const path = require('path');
const browserIndexCandidates = [
path.join(__dirname, '../dist/frontend/browser/index.html'),
path.join(__dirname, '../dist/frontend/browser/index.csr.html'),
];
const serverDestFile = path.join(__dirname, '../dist/frontend/server/index.server.html');
const browserCsrFile = path.join(__dirname, '../dist/frontend/browser/index.csr.html');
try {
const sourceFile = browserIndexCandidates.find((candidate) => fs.existsSync(candidate));
if (!sourceFile) {
throw new Error('Angular build did not produce dist/frontend/browser/index.html or index.csr.html');
}
fs.copyFileSync(sourceFile, serverDestFile);
if (sourceFile !== browserCsrFile) {
fs.copyFileSync(sourceFile, browserCsrFile);
}
console.log('✓ Synced built index template to server/index.server.html and browser/index.csr.html');
} catch (err) {
console.error('✗ Failed:', err.message);
process.exit(1);
}
Why this matters:
The Angular build injects <link> and <script> tags into index.html:
<!-- Before build (src/index.html) -->
<head>
<title>My App</title>
</head>
<!-- After build (dist/frontend/browser/index.html) -->
<head>
<title>My App</title>
<link rel="stylesheet" href="styles.css"> <!-- INJECTED -->
<script src="polyfills.js" type="module"></script> <!-- INJECTED -->
<script src="main.js" type="module"></script> <!-- INJECTED -->
</head>
We must use the built version with these injections, not the raw source file. Otherwise styles and scripts won’t load.
API Proxy Configuration (Why You Can’t Use proxy.conf.json)
The Problem with Angular’s Built-in Proxy
If you’ve used ng serve before, you might have a proxy.conf.json file that looks like this:
{
"/api": {
"target": "http://localhost:4000",
"secure": false
}
}
And you run it with:
ng serve --proxy-config proxy.conf.json
This works great with ng serve. But here’s the catch: it ONLY works with ng serve.
When you run a custom Express server (like we’re doing for SSR), Angular’s dev server isn’t running. So proxy.conf.json is completely ignored. Your /api requests go nowhere.
What happens:
- Your frontend makes a request to
/api/auth/profile - Express looks for a route handler for
/api/auth/profile - Doesn’t find one (you only have the SSR catch-all route)
- Returns your Angular app HTML as the response
- Frontend tries to parse HTML as JSON → error
The solution: Implement the proxy yourself in the Express server using Node.js’s built-in http module.
Manual Proxy Implementation
The Express server includes a native Node.js HTTP proxy (no dependencies needed):
server.use('/api', (req: Request, res: Response) => {
const targetPath = `/api${req.url}`;
console.log(`[PROXY] ${req.method} ${targetPath} → http://localhost:4000`);
const options = {
hostname: 'localhost',
port: 4000,
path: targetPath,
method: req.method,
headers: req.headers // Forward all headers (including cookies!)
};
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);
});
How it works:
- Request to
http://localhost:4200/api/auth/profile - Proxy forwards to
http://localhost:4000/api/auth/profile - Response piped back to client
- Cookies preserved in both directions
Verification
# Terminal 1: Start backend
cd backend
npm start # Should run on port 4000
# Terminal 2: Start SSR dev server
cd frontend
npm run start:ssr:dev # Should run on port 4200
# Test proxy
curl http://localhost:4200/api/auth/profile
# Should see [PROXY] logs and get response from backend
Running and Testing
Step 1: Start Backend
cd backend
npm start
# Backend should run on localhost:4000
Step 2: Build and Start SSR Server
cd frontend
# Build browser + server bundles, then start Express
npm run start:ssr:dev
# You should see:
# ✓ Synced built index template to server/index.server.html
# Node Express server listening on http://localhost:4200
Step 3: Verify SSR is Working
Test 1: View Page Source
- Open http://localhost:4200
- Right-click → “View Page Source” (Ctrl+U / Cmd+U)
- ✅ Success: Full HTML content inside
<app-root> - ❌ Failure: Empty
<app-root></app-root>
Test 2: Check Styles Load
- Open http://localhost:4200
- Page should be fully styled (not unstyled HTML)
- Open DevTools → Network tab
- ✅ Success:
styles.cssloads with 200 status - ❌ Failure: 404 or missing styles
Test 3: Test API Proxy
- Open DevTools → Console
- Try logging in or making API calls
- Check terminal where Express is running
- ✅ Success: See
[PROXY] GET /api/auth/profile → http://localhost:4000logs - ❌ Failure: No proxy logs or connection errors
Test 4: Verify Environment
# Check that development environment is used
grep "apiUrl" dist/frontend/browser/main.js
# Should show: apiUrl:"/api" (not production URL)
Test 5: Test Auth Flow
- Navigate to protected route (e.g.,
/dashboard) - If not logged in, should redirect to
/login - Log in with credentials
- Should redirect back to
/dashboard - Refresh page (F5)
- ✅ Success: Stay on
/dashboard(no login flash) - ❌ Failure: Kicked to
/login
Step 4: Test Hot-Reload Workflow
SSR doesn’t have true hot-reload, but you can rebuild quickly:
# Make a code change in src/
# Rebuild (Ctrl+C to stop server first)
npm run start:ssr:dev
# Or just rebuild without restarting:
npm run build:ssr:dev
# Then refresh browser (server doesn't need restart)
Common Pitfalls
Pitfall 1: Port 4200 Already in Use
Problem: Server won’t start, port conflict
Fix:
# Windows
netstat -ano | findstr :4200
taskkill /PID <PID> /F
# Mac/Linux
lsof -ti:4200 | xargs kill -9
# Or use different port
cross-env PORT=4200 ts-node --esm server.ts
Pitfall 2: Module Not Found Error
Problem:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module 'main.js'
Fix:
- Verify build completed:
ls dist/frontend/server/main.js - If missing, run build manually:
ng run frontend:server:development
Pitfall 3: JIT Compilation Error
Problem:
The injectable 'PlatformLocation' needs to be compiled using the JIT compiler,
but '@angular/compiler' is not available
Fix: Verify server.ts imports @angular/compiler first:
import 'zone.js/node';
import '@angular/compiler'; // ← Must be here!
import { APP_BASE_HREF } from '@angular/common';
Pitfall 4: Styles Not Loading
Problem: Page renders but has no styles
Cause: Using raw src/index.html instead of built version
Fix: Verify scripts/copy-index.js runs and copies from dist/frontend/browser/index.html
Pitfall 5: Browser Caching Old Bundle
Problem: Code changes don’t appear even after rebuild
Fix: Hard refresh browser:
- Windows/Linux:
Ctrl + Shift + RorCtrl + F5 - Mac:
Cmd + Shift + R - Or: DevTools → Network → “Disable cache” → Refresh
Pitfall 6: Wrong Environment Variables
Problem: API requests go to production URL instead of /api
Fix: Verify fileReplacements in angular.json and check built bundle:
grep "apiUrl" dist/frontend/browser/main.js
# Should show: apiUrl:"/api"
Pitfall 7: Proxy Not Working
Problem: API requests fail with connection refused
Fix:
- Verify backend is running on port 4000
- Check Express logs show
[PROXY]messages - Verify environment uses
apiUrl: '/api'
Pitfall 8: Windows Path Errors
Problem:
Error [ERR_UNSUPPORTED_ESM_URL_SCHEME]: Only file and data URLs are supported
Fix: Use pathToFileURL() for module imports:
import { pathToFileURL } from 'node:url';
const serverBundlePath = pathToFileURL(join(serverDistFolder, 'main.js')).href;
Troubleshooting
Issue: Server Crashes on Startup
Symptoms: Express server starts then immediately crashes
Diagnosis:
# Run with verbose error logging
node --trace-warnings dist/frontend/server.js
Common causes:
- Missing dependencies (
npm install) - Server bundle not built (
npm run build:ssr:dev) - Port already in use
- Import path errors
Issue: SSR Not Rendering (Empty )
Symptoms: Page source shows <app-root></app-root> with no content
Diagnosis:
- Check Express terminal for errors
- Look for “SSR rendering error:” messages
- Check browser console for errors
Common causes:
renderApplication()not imported correctly- Bootstrap function not found
- Platform-specific code running on server
Fix: Add SSR guards:
if (isPlatformBrowser(this.platformId)) {
// Browser-only code
}
Issue: API Calls Fail
Symptoms: 404 or connection refused on /api requests
Diagnosis:
- Check backend is running:
curl http://localhost:4000/health - Check proxy logs in Express terminal
- Verify environment:
console.log(environment.apiUrl)
Fix:
- Ensure backend runs on port 4000
- Verify
apiUrl: '/api'in development environment - Check proxy middleware is before SSR route handler
Issue: Authentication Doesn’t Work
Symptoms: Always redirected to login, even after successful auth
Diagnosis:
- Check browser Network tab → Headers
- Verify
Set-Cookieheader in login response - Check subsequent requests include
Cookieheader
Common causes:
withCredentials: truemissing in HTTP interceptor- httpOnly cookies blocked by browser settings
- AUTH check running during SSR (should be skipped)
Fix: Verify app.config.ts skips auth during SSR:
if (!isPlatformBrowser(platformId)) {
return Promise.resolve();
}
Issue: Build Succeeds But Old Code Runs
Symptoms: Changes don’t appear after rebuild
Cause: Browser cache or server didn’t reload bundle
Fix:
- Hard refresh browser (Ctrl+Shift+R)
- Restart Express server (Ctrl+C then
npm run start:ssr:dev) - Clear dist folder:
rm -rf dist && npm run build:ssr:dev
Issue: TypeScript Compilation Errors
Symptoms:
error TS2307: Cannot find module 'express' or its corresponding type declarations
Cause: server.ts included in Angular TypeScript compilation
Fix: Verify tsconfig.app.json and tsconfig.server.json both exclude server.ts
Performance Tips
Build Times
- Initial build: 15-20 seconds
- Incremental rebuild: 10-15 seconds
- Consider using
--watchmode for faster iterations (experimental)
Bundle Sizes
Development builds:
- Browser: ~4.9 MB (unoptimized)
- Server: ~8.5 MB (includes all dependencies)
This is normal for dev. Production builds are much smaller (see Part 1).
Memory Usage
- Node.js process: ~200-300 MB
- If memory issues occur, increase Node heap:
cross-env NODE_OPTIONS="--max-old-space-size=4096" npm run start:ssr:dev
Quick Reference
Start SSR Dev Server
cd frontend
npm run start:ssr:dev
# Opens on http://localhost:4200
Rebuild Only (Without Restarting Server)
npm run build:ssr:dev
# Then refresh browser
Verify Environment
grep "apiUrl" dist/frontend/browser/main.js
# Should show: apiUrl:"/api"
Clear Everything and Rebuild
rm -rf dist node_modules/.cache
npm run build:ssr:dev
Test Proxy
curl http://localhost:4200/api/health
# Should forward to backend
Success Checklist
-
cross-envandts-nodeinstalled as dev dependencies -
angular.jsonhas separateservertarget withfileReplacements -
tsconfig.server.jsonexcludesserver.ts -
environment.development.tshasapiUrl: '/api' -
server.tsimports@angular/compilerbefore Angular code -
server.tsusespathToFileURL()for module imports -
server.tsincludes API proxy middleware -
scripts/copy-index.jscopies builtindex.html -
package.jsonhasstart:ssr:devandbuild:ssr:devscripts - Backend runs on
localhost:4000 -
npm run start:ssr:devstarts server onlocalhost:4200 - Page source shows rendered HTML (not empty
<app-root>) - Styles load correctly
- API proxy works (see
[PROXY]logs) - Authentication works without login flash
Next Steps
With local SSR working, you’re ready to:
- Test SSR-specific features (meta tags, canonical URLs)
- Debug SSR issues before deployment
- Develop with real backend (via proxy)
- Deploy to Netlify (see Part 1 guide)
Additional Resources
- Angular SSR Guide - Official documentation
- Express Documentation - Express server API
- Node.js ESM - Module resolution
- Part 1: Netlify Deployment - Deploy to production
Questions or feedback? Reach out via contact form or @stackinsightDev
This guide reflects the actual working implementation used in development. All configurations tested with Angular 20.2, Node 20, and Express 4.18.