Angular SSR Bootstrap: provideServerRendering, server.ts and Request Tokens [2026]

Link copied
Angular SSR Bootstrap: provideServerRendering, server.ts and Request Tokens [2026]

Angular SSR Bootstrap: provideServerRendering, server.ts and Request Tokens [2026]

Running ng add @angular/ssr takes a few seconds and leaves you with four new files you didn't write. The app renders on the server, until something fails with window is not defined, a 404 page comes back with status 200, or a cookie you need on the server isn't there. Fixing those problems is much easier once you know what each file does, which code runs where, and how a request flows from Node into your components and back.

This is lesson 9.2 of the Angular Tutorial. Lesson 9.1 chose a render mode for each route; this lesson opens up the machinery behind those modes. It assumes you know the client-side bootstrap from lesson 1.6. You'll learn what provideServerRendering() configures, how server.ts hands requests to AngularNodeAppEngine, the lifecycle of a server-rendered request, how to read the request and set the response from inside components, and how to keep browser-only code off the server.

The two applications in one codebase #

An SSR-enabled Angular project builds the same components twice, once for each platform:

Browser build Server build
Entry point main.ts main.server.ts
Application config app.config.ts app.config.server.ts (merges app.config.ts)
Routes app.routes.ts app.routes.ts + app.routes.server.ts
Who calls bootstrap The browser, once per page load The app engine, once per request
Has window, document, localStorage Yes No (Angular provides a server-side DOCUMENT)
Output Static JS and CSS A server bundle plus your server.ts handler

The key line is "once per request". On the server, every request creates a fresh application with its own injector, renders one URL, and is thrown away. Services marked providedIn: 'root' are singletons per request, not per server process, which is what keeps one user's state from leaking into another user's page.

main.server.ts: the server bootstrap #

// main.server.ts
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;

The engine calls this function for every request and passes a BootstrapContext, which gives the application a platform scoped to that request. If you upgraded an older project and see a "missing platform" error (NG0401), this context argument is usually what's missing.

app.config.server.ts: provideServerRendering() #

// app.config.server.ts
import { ApplicationConfig, mergeApplicationConfig } 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);

mergeApplicationConfig takes everything from the browser config (router, HttpClient, hydration) and adds server-only providers on top. provideServerRendering() sets up the server platform, and its features decide what the engine knows about your routes:

Feature / option What it does
withRoutes(serverRoutes) Registers the ServerRoute[] from lesson 9.1: render modes, headers, status codes, prerender params
withAppShell(Component) Renders the given component as an app shell for client-rendered routes, so they show a skeleton instead of a blank page
{ maxResponseBodySize } (first argument) Raises the 1 MB limit on HTTP response bodies your app fetches while rendering on the server. Keep it as small as you can

Because the server config is just more providers, it's also where you swap implementations per platform. If AnalyticsService should send events from the browser but do nothing (or log) on the server, provide BrowserAnalyticsService in app.config.ts and override it with ServerAnalyticsService in serverConfig. Components inject the abstract class and never check the platform.

server.ts: where requests come in #

The CLI generates an Express server. Trimmed to the essentials:

// server.ts
import {
  AngularNodeAppEngine,
  createNodeRequestHandler,
  isMainModule,
  writeResponseToNodeResponse,
} from '@angular/ssr/node';
import express from 'express';
import { join } from 'node:path';

const browserDistFolder = join(import.meta.dirname, '../browser');
const app = express();
const angularApp = new AngularNodeAppEngine();

// 1. Static files (JS, CSS, images) straight from disk
app.use(express.static(browserDistFolder, { maxAge: '1y', index: false, redirect: false }));

// 2. Everything else goes to Angular
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(`Listening on http://localhost:${port}`));
}

// Used by the dev server, build-time route extraction and serverless hosts
export const reqHandler = createNodeRequestHandler(app);

Three things are worth knowing about this file:

  • AngularNodeAppEngine should be created once and reused for every request. It loads the server bundle and route manifest; creating one per request wastes memory and time.
  • handle() returns null when the URL isn't an Angular route it can serve. That's why the code calls next(): your own API routes, a 404 handler or other middleware can take over.
  • reqHandler is the export that matters to tooling. ng serve uses it in development, and many hosting adapters import it instead of calling listen().

You can add your own Express routes (app.get('/api/health', ...)) above the Angular handler. Keep them small: the server is a rendering tier, not a replacement for your backend.

Host validation #

The engine checks the request's host name against a list of allowed hosts to prevent server-side request forgery, and requests from unrecognised hosts get a 400 Bad Request. Configure the list in angular.json under the build options' security.allowedHosts, pass allowedHosts to the AngularNodeAppEngine constructor, or set the NG_ALLOWED_HOSTS environment variable (comma-separated) for Node. X-Forwarded-* and Forwarded headers are untrusted by default; if you run behind a proxy that sets them, the engine has to be told to trust them. If your app works locally but returns 400 in production, check this first.

The lifecycle of a server-rendered request #

For a route with RenderMode.Server, one request goes through these steps:

  1. Express receives the request; the static middleware doesn't match it.
  2. angularApp.handle(req) matches the URL against your server routes. A prerendered route is served from its built HTML file; a client route gets the client-side index.html; a server route continues.
  3. The engine calls your bootstrap function with a fresh BootstrapContext. A new application, new injector and new root services are created.
  4. The router navigates to the URL. Guards and resolvers run; components are created; templates render into a server-side DOM.
  5. The engine waits until the application is stable: no pending HTTP requests or other tracked pending tasks.
  6. Angular serializes the DOM to HTML, adds hydration annotations and the transfer-state script (lesson 9.6), applies the status and headers, and returns a web Response.
  7. writeResponseToNodeResponse writes it to the Node response. The application is destroyed.

Step 5 explains two common symptoms. A page that hangs on the server usually has a task that never completes, such as an interval or a never-ending observable started during rendering. A page that renders without its data usually started async work Angular doesn't track, such as a raw fetch() in a constructor. HttpClient and resource() are tracked. For anything else, wrap it with inject(PendingTasks).run(async () => ...) so the server waits for it.

Reading the request and shaping the response #

Inside the application, three injection tokens give you access to the current request:

Token Type Use it for
REQUEST Web Request Reading URL, headers and cookies of the incoming request
RESPONSE_INIT ResponseInit Setting the status code of the response from a component
REQUEST_CONTEXT unknown (whatever you pass) Extra data from server.ts, such as a locale or tenant ID resolved by middleware

All three are null when there is no request: in the browser, during prerendering at build time, and during route extraction. Always handle null.

A not-found page that returns a real 404 status:

import { Component, RESPONSE_INIT, inject } from '@angular/core';

@Component({
  selector: 'app-not-found',
  template: `<h1>Page not found</h1>`,
})
export class NotFound {
  constructor() {
    const responseInit = inject(RESPONSE_INIT);
    if (responseInit) {
      responseInit.status = 404;   // without this, crawlers see a 200 "soft 404"
    }
  }
}

Reading a header from the request, and passing context from Express:

// in a service or component
const request = inject(REQUEST);
const language = request?.headers.get('accept-language') ?? 'en';

// in server.ts: pass extra data as the second argument to handle()
angularApp.handle(req, { tenant: res.locals['tenant'] });

// in the app
const context = inject(REQUEST_CONTEXT) as { tenant: string } | null;

For fixed status codes and headers, prefer the status and headers fields on the server route from lesson 9.1. Use RESPONSE_INIT when the decision depends on data loaded during rendering.

Keeping browser-only code off the server #

Components run on both platforms, so code that touches browser globals needs a guard. In order of preference:

Approach When to use it
inject(DOCUMENT) instead of document Reading or changing the document (title, meta, canonical link). Works on both platforms
afterNextRender(() => ...) Code that needs the real DOM or browser APIs: measuring elements, charts, localStorage, IntersectionObserver. It never runs on the server
Platform-specific providers A whole service behaves differently per platform (analytics, storage)
isPlatformBrowser(inject(PLATFORM_ID)) Last resort, inside logic (not templates) that must branch
import { Component, ElementRef, afterNextRender, inject, viewChild } from '@angular/core';

@Component({
  selector: 'app-sales-chart',
  template: `<canvas #canvas></canvas>`,
})
export class SalesChart {
  private canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');

  constructor() {
    afterNextRender(() => {
      // runs only in the browser, after the first render
      const saved = localStorage.getItem('chart-range') ?? '30d';
      drawChart(this.canvas().nativeElement, saved);
    });
  }
}

Avoid @if (isBrowser) in templates. Rendering different markup on the server and in the browser causes hydration mismatches and layout shifts, which is the subject of lesson 9.3.

Gotchas #

Symptom Cause Fix
ReferenceError: window is not defined Browser global used in a constructor, field initializer or service Move it into afterNextRender, or inject DOCUMENT
400 Bad Request in production only Host isn't in the allowed hosts list Add it to security.allowedHosts, the engine options or NG_ALLOWED_HOSTS
Server render hangs or times out A timer or open observable keeps the app unstable Start it in afterNextRender, or complete it on the server
Page renders without its data Untracked async work (raw fetch, promises) Use HttpClient/resource(), or wrap with PendingTasks.run()
Not-found page returns status 200 Status never set Set RESPONSE_INIT.status = 404, or status: 404 on the server route
inject(REQUEST) is null Running in the browser or during prerendering Handle null; use RenderMode.Server for request-dependent pages

What's next #

Lesson 9.3 covers what happens when the server HTML reaches the browser: non-destructive hydration with provideClientHydration(), the things that break it (direct DOM manipulation, invalid HTML, platform-dependent templates), and when ngSkipHydration is the right escape hatch.

Try it yourself #

Find every use of window, document, localStorage and navigator in your app's components and services. For each one, decide which approach from the table above fits, and move the riskiest one into afterNextRender. Then add a not-found component that sets a 404 status through RESPONSE_INIT.

YouOur theme service reads localStorage in its constructor, and SSR now crashes with “localStorage is not defined”. What’s the cleanest fix?
Claude · used get_best_practicesSplit the storage access out of ThemeService. Create an abstract ThemeStorage with read() and write(), provide a LocalThemeStorage in app.config.ts, and override it with a ServerThemeStorage in app.config.server.ts that reads the theme from a cookie via inject(REQUEST)?.headers.get('cookie'). That way the server renders the user’s real theme instead of a default, so there’s no flash and no hydration mismatch when the browser takes over. Avoid wrapping the constructor in isPlatformBrowser: the server would render the light theme, the browser would switch to dark, and users would see the swap. If the theme only affects things the server never renders, an afterNextRender callback in the root component is enough.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *