Angular SSR hosted by ASP.NET Core is a production topology: public HTML rendered ahead of time or on a Node SSR process, APIs still owned by ASP.NET Core, and a reverse proxy that sends /api one place and document requests another — without breaking cookies or hydration.
Browser
│
▼
Reverse proxy / App Service / YARP
├─ /api/* → ASP.NET Core API
├─ /auth/* → API or BFF
└─ /* → Angular SSR (Node) or static prerender + MapFallbackToFile
New to this → stay here. Local Compose → Docker .NET + Angular. BFF cookies → BFF + YARP.
Search intent for angular ssr asp.net core is how-to on hosting layout, not “what is Universal” theory.
SSR vs prerender vs CSR: choose per product surface
| Mode | What runs at request time | Good for |
|---|---|---|
| CSR | Browser downloads shell + JS, then data | Authenticated SaaS apps |
| Prerender | Build-time HTML for known routes | Marketing, docs, public landing |
| SSR | Node renders HTML per request | Public pages needing fresh data + SEO |
I split surfaces in healthcare/SaaS portfolios:
www.example.commarketing → prerender or SSRapp.example.comclinician shell → CSR (often with BFF)- Public provider directory → SSR/prerender
Do not force SSR onto every authenticated charting screen. You pay complexity (TransferState, cookie forwarding, hydration) for little SEO gain behind login.
Where ASP.NET Core serves HTML vs API
Two workable layouts:
A) ASP.NET Core serves static prerender + API (simplest)
Angular build outputs browser files (and prerendered routes) into wwwroot or a known folder. ASP.NET Core:
app.MapControllers(); // or MapGroup("/api")
app.MapHub<NotifyHub>("/hubs/notify");
app.UseDefaultFiles();
app.UseStaticFiles();
// AFTER API routes
app.MapFallbackToFile("index.html");
Route precedence: map APIs before fallback. Otherwise GET /api/patients returns index.html and Angular looks “broken” while Network shows 200 HTML.
B) Node Angular SSR + ASP.NET Core API behind one proxy (true SSR)
- Process 1: Kestrel API
- Process 2:
node server.mjs(Angular SSR) - Proxy:
/api→ Kestrel, everything else → Node
YARP or nginx/Caddy/App Service multi-container all work. ASP.NET Core does not have to execute Angular SSR itself.
// YARP sketch in a BFF/gateway project
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
// routes: api-cluster → api:8080, ssr-cluster → ssr:4000
Node SSR process vs prerender-only pipelines
Prerender-only (often enough):
ng build --configuration production
# outputs prerendered HTML for routes listed in angular.json
CI copies dist/.../browser into the API’s static file root or a CDN + API origin.
SSR Node process:
- Needs CPU/memory sizing separate from Kestrel
- Must forward cookies/auth headers carefully for personalized HTML
- Health checks should probe Node as well as the API (health checks)
If your “SSR” need is SEO for ten marketing routes, prerender wins. If pages need per-request data that changes every minute and must appear in first HTML, SSR earns its keep.
Cookies, auth, and TransferState boundaries
Rules that keep SaaS/healthcare teams out of trouble:
- Do not put access tokens in TransferState. Anything in SSR HTML is world-readable in view-source.
- Prefer anonymous or public DTOs in SSR. Personalized PHI belongs after hydration via authenticated API calls.
- HttpOnly cookie BFF sessions can be forwarded to the API from Node only if you treat Node as a trusted peer on a private network — still do not echo secrets into HTML.
- CORS matters less when same-origin via proxy; it returns if you host API on another origin (CORS).
TransferState example for public catalog data only:
// server: fetch public directory, set to TransferState
// browser: read TransferState first to avoid duplicate GET
const KEY = makeStateKey<ProviderDirectoryDto>('provider-directory');
if (isPlatformServer(platformId)) {
const data = await firstValueFrom(this.api.getPublicDirectory());
transferState.set(KEY, data);
return data;
}
return transferState.get(KEY, null) ?? await firstValueFrom(this.api.getPublicDirectory());
Deploy sketch: App Service / containers / reverse proxy
App Service (API + static)
- Build Angular → artifact
browser/ - Copy into ASP.NET Core
wwwrootin CI - Publish API
- Deploy one App Service (App Service guide)
Good for prerender/CSR hybrid. Not ideal for heavy Node SSR unless you use a second site or container.
Containers
service api → aspnet image :8080
service ssr → node image :4000
service proxy → nginx/yarp :443
Same VNet; only proxy is public. Align with local Docker habits but bake production TLS and health endpoints.
CDN + API
Static/prerender on CDN/blob; API on App Service. Deep links for SPA still need fallback rules on the static host (index.html for unknown paths) except /api/* which never hits the CDN app shell.
Deep links, MapFallbackToFile, and API route precedence
Broken deep links (/providers/123 refresh → 404) mean the static host lacks fallback. Broken APIs mean fallback stole /api.
Checklist:
- Register all API endpoints first
- Then static files
- Then
MapFallbackToFile("index.html")excluding/api,/health,/hubs - If using PathBase / virtual directories, test refresh on nested routes
app.MapGroup("/api").MapPatientEndpoints();
app.MapHealthChecks("/health/live");
app.MapFallbackToFile("{*path:regex(^(?!api|health|hubs).*$)}", "index.html");
// or use a clearer two-app proxy layout and avoid regex heroics
Standalone Angular routing still owns client paths (standalone components).
Hydration mismatch footguns (preview)
Hydration fails when server HTML and browser first render disagree:
Date.now(), random ids, locale-dependent formatting in templates- Auth-gated
@if (user())rendering differently on server (often null) vs browser - CSS that depends on
windowmeasurements
Mitigations: keep SSR markup public/stable; defer personalized blocks to afterNextRender; fix clocks/locales. Deep hydration debugging is a sibling topic — here: do not SSR personalized PHI chrome.
When SSR is not worth it for authenticated SaaS shells
Skip full SSR when:
- Nearly all routes require login
- SEO is irrelevant for PHI screens
- Team does not want to operate Node + .NET
- LCP issues are actually giant JS bundles / unbounded tables — fix CSR performance first
Use prerender for marketing only, CSR for app., and put effort into API performance, OnPush, and bundle budgets.
Production checklist
- Decide per surface: CSR vs prerender vs SSR
- Document topology (who serves HTML, who serves
/api) - API routes registered before SPA fallback
- No secrets/tokens in TransferState or prerender HTML
- Health checks cover every process you deploy
- Deep link refresh tested on production-like host
- Auth cookies work same-site with proxy
- Hydration: public pages clean; app shell may stay CSR
Common mistakes I still see
MapFallbackToFilebefore Minimal APIs — HTML for JSON routes- SSR fetching with user access tokens baked into HTML
- One App Service trying to run Node SSR without a process plan
- Treating Compose local as production topology
- Forcing SSR on the entire clinician app for “Google ranking”
Verification
curl -I https://host/api/health→ API content-type, nottext/htmlcurl https://host/providers/some-slug→ HTML includes meaningful public content (prerender/SSR)- Browser refresh on client route → 200 app shell, then hydrate
- View-source on SSR page → no bearer tokens or PHI payloads
- Proxy logs show
/apiand/hitting different upstreams
If an interviewer asks
How would you host Angular SSR with ASP.NET Core?
Strong answer: Split HTML and API at the proxy. Prefer prerender for static marketing; Node SSR only when HTML must be request-fresh. ASP.NET Core owns /api and auth. Never put tokens in TransferState. Authenticated SaaS shells often stay CSR.
Related: Docker local Angular + .NET · App Service · BFF YARP · CORS · Standalone Angular
Concrete folder layout for prerender-into-wwwroot
Clinic.Api/
wwwroot/ # CI copies Angular browser output here
Program.cs
Clinic.Web/ # Angular workspace
projects/web/
CI sketch:
- run: npm ci
working-directory: Clinic.Web
- run: npm run build -- --configuration production
working-directory: Clinic.Web
- run: |
rm -rf ../Clinic.Api/wwwroot/*
cp -R dist/web/browser/* ../Clinic.Api/wwwroot/
- run: dotnet publish Clinic.Api -c Release -o publish
Verify wwwroot/index.html exists before publish. Missing copy is the #1 “blank App Service” failure.
Same-origin BFF vs separate API origin with SSR
Same-origin (preferred with SSR): proxy serves HTML and /api together. Browser cookies are first-party. SSR Node calls http://api:8080 over the private network, not the public JWT in HTML.
Separate API origin: Angular SSR must use absolute API URLs carefully; browser calls need CORS; cookie auth gets painful. Prefer BFF (YARP BFF).
Caching headers for prerendered marketing pages
Prerendered HTML can be CDN-cached. API responses for personalized data must not inherit those headers.
// API endpoints
context.Response.Headers.CacheControl = "no-store";
Static assets from Angular (*.js hashed names) get long cache; index.html gets short cache or revalidation so new deploys roll out.
SSR and CSP nonces (pointer)
If you ship a strict Content Security Policy, SSR must inject the same nonce into rendered script tags that ASP.NET Core emits for CSR index middleware. Coordinate with the CSP post — mismatched nonces break hydration/boot.
Staging verification script
# API not captured by SPA fallback
curl -s -o /dev/null -w "%{content_type}\n" https://staging/api/health
# expect application/json
# marketing route has product name in first HTML
curl -s https://staging/pricing | grep -i "pricing"
# no authorization headers leaked
curl -s https://staging/pricing | grep -i "bearer" && echo FAIL || echo OK
Team operating model
Document owners:
- API team — Kestrel, auth, ProblemDetails
- Web team — Angular routes, prerender list, hydration
- Platform — proxy, TLS, health probes for Node + API
Without owners, SSR becomes an orphan process that nobody patches.
Choosing routes for prerender lists
In angular.json (or application config), list only public routes:
"prerender": {
"routes": ["/", "/pricing", "/providers", "/providers/featured"]
}
Do not prerender /charts/:id or /patients/:id. Those need auth and personalization. Dynamic provider pages can use SSR or a generate-params script that pulls a public sitemap from the API at build time — with a hard cap so CI does not scrape unbounded PHI-adjacent data.
Local production-parity run
ng build --configuration production- Copy to
wwwroot dotnet runwith Production environment- Disable Angular proxy; hit Kestrel origin only
This catches fallback order bugs that ng serve never shows. Pair with Docker local when you need Node SSR + API together.
Environment-specific API base URLs for SSR vs browser
On the server, Angular should call an internal URL (http://api:8080); in the browser, relative /api same-origin. Use providers:
export const API_BASE = new InjectionToken<string>('API_BASE');
// server.ts providers: { provide: API_BASE, useValue: process.env['API_INTERNAL'] }
// browser: { provide: API_BASE, useValue: '' } // relative
Mixing them causes SSR to call localhost incorrectly in containers or double-fetch with CORS failures.
Error pages and status codes for prerendered routes
Marketing 404s should return real HTTP 404 with a prerendered not-found body when possible. SPA-only fallback that returns 200 for unknown URLs hurts SEO. Configure the host carefully: unknown API paths → API 404 JSON; unknown page paths → HTML 404 or app shell based on product choice.
Observability for two processes
If Node SSR and Kestrel both run, health and logs must cover both. A green API with a dead SSR process means homepage 502 while /api/health still passes — split probes at the proxy.
Auth cookie SameSite with split hosts
If marketing is www (SSR) and app is app (CSR) on different subdomains, cookies need deliberate Domain and SameSite settings. Prefer a BFF on the app host for session cookies rather than sharing auth cookies with the public SSR site. Public SSR should not need the clinician session cookie at all.
Incremental adoption plan
- Ship CSR app + API (already working)
- Prerender marketing routes into wwwroot
- Measure SEO/LCP
- Add Node SSR only for routes that still need request-time HTML
- Stop — do not SSR the authenticated shell without a measured goal
Content negotiation footgun
Do not serve index.html for Accept: application/json on /api/*. Keep API and HTML pipelines separate at the proxy. This is the same class of bug as fallback stealing API routes.