+1 (726) 227-3745

Server-Side Rendering a MEAN App: Angular SSR and Incremental Hydration on Express 5

Most MEAN apps we inherit are client-side only: the browser downloads the Angular bundle, boots, and then starts calling the Express API. That costs a blank screen on first paint, weak Core Web Vitals, and — for public-facing pages — patchy indexing. Angular's SSR story is now stable and pleasant to use, and because it renders inside a Node server it drops straight into the "E" of MEAN: your existing Express 5 app can render Angular and serve /api from the same process.

This tutorial takes an existing Angular 20+ / Express 5 / Mongoose 8 application and adds SSR, state transfer, and incremental hydration so only the parts of the page a user actually touches get hydrated. Everything below assumes Node.js 22 LTS or newer, Angular 20 or later (the incremental hydration API is stable from Angular 20), and Express 5.

What SSR actually buys you (and what it does not)

Before the code, set expectations — we say the same thing on client calls:

  • It helps: first contentful paint, largest contentful paint, social/link previews, crawlers that do not execute JavaScript well, and perceived speed on slow devices.
  • It does not help: total interactivity time, unless you also reduce hydration work. A server-rendered page that then hydrates a 900 KB component tree can feel worse than CSR, because the page looks ready but ignores clicks. That is exactly the gap incremental hydration plus event replay closes.
  • It costs: a Node process you must now keep warm, monitor and scale; code that must not touch window, document or localStorage at module load; and a second place where your API credentials live.

If your app is entirely behind a login and has no SEO or first-paint problem, SSR is usually not worth the operational overhead. Say no early rather than half-migrating.

Step 1: add SSR to the existing Angular app

From the Angular workspace root:

ng add @angular/ssr

This is non-destructive on an existing app. It adds src/server.ts, an app.config.server.ts, wires provideServerRendering() into the server config, and adds a server target plus "ssr" options to angular.json. Build and run it once to confirm nothing is broken:

ng build
node dist/<your-app>/server/server.mjs

Hit http://localhost:4000 and view source. If you see real markup instead of <app-root></app-root>, SSR is alive.

Common first-run failures, in the order we usually hit them:

  • ReferenceError: window is not defined — a component or third-party library touches browser globals during construction. Guard it with isPlatformBrowser(inject(PLATFORM_ID)) or move the call into afterNextRender().
  • localStorage is not defined in an auth interceptor — read tokens from a cookie on the server instead (see step 4).
  • Infinite hydration timeouts — usually a setInterval or a never-completing observable keeping Angular's zone busy. Zoneless apps avoid most of this; if you are still zone-based, wrap timers in NgZone.runOutsideAngular().

Step 2: one Node process for SSR and the API

The generated server.ts gives you an Express app. Mount your existing API router on it rather than running two servers — one process, one deployment, no CORS.

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

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

app.use(express.json());

// 1. Your existing Express 5 API, unchanged.
app.use('/api', apiRouter);

// 2. Static build output, hashed filenames so cache hard.
app.use(express.static(browserDistFolder, {
  maxAge: '1y',
  index: false,
  redirect: false,
}));

// 3. Everything else is 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 = Number(process.env.PORT ?? 4000);
  await connectDb(process.env.MONGODB_URI!);
  app.listen(port, () => console.log(`SSR + API listening on ${port}`));
}

export const reqHandler = createNodeRequestHandler(app);

Note the ordering: API first, static second, Angular catch-all last. Reversing the first two is the classic bug — the catch-all swallows /api/* and your XHRs start returning HTML.

Mongoose connection handling deserves a word. Call connectDb() once at boot, not per request, and set a small pool (maxPoolSize: 10) plus serverSelectionTimeoutMS: 5000 so a database blip fails a render fast instead of hanging the event loop for 30 seconds.

Step 3: stop double-fetching with TransferState

By default the server renders a page using data from your API, then the browser boots and fetches the exact same data again. provideClientHydration() plus withHttpTransferCacheOptions fixes GET requests for free:

// app.config.ts
import { provideClientHydration, withHttpTransferCacheOptions, withEventReplay, withIncrementalHydration } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(withFetch()),
    provideClientHydration(
      withEventReplay(),
      withIncrementalHydration(),
      withHttpTransferCacheOptions({
        includePostRequests: false,
        filter: (req) => req.url.startsWith('/api/') && !req.url.includes('/api/me'),
      }),
    ),
  ],
};

Two details that bite people:

  • withFetch() is required for the transfer cache to behave predictably. Use it.
  • Filter out per-user responses. A response cached into the transferred state is embedded in the HTML. If a CDN or reverse proxy caches that HTML, user A's /api/me payload can be served to user B. Our rule: only public, cacheable GETs go in the transfer cache; anything user-specific is excluded and refetched in the browser.

For data you fetch outside HttpClient (a direct Mongoose call in a resolver, say), use TransferState manually:

const PRODUCTS = makeStateKey<Product[]>('products');
const state = inject(TransferState);

readonly products = signal<Product[]>(state.get(PRODUCTS, []));

Step 4: authentication that works on both sides

On the server there is no localStorage. The pattern we standardise on:

  1. Session/refresh token lives in an HttpOnly; Secure; SameSite=Lax cookie.
  2. On the server, read the incoming request via the REQUEST injection token and forward the cookie to your API call.
  3. In the browser, the cookie rides along automatically with withCredentials.
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const platformId = inject(PLATFORM_ID);
  if (isPlatformBrowser(platformId)) return next(req);

  const serverReq = inject(REQUEST, { optional: true });
  const cookie = serverReq?.headers.get('cookie');
  const url = req.url.startsWith('/') ? `http://127.0.0.1:${process.env.PORT ?? 4000}${req.url}` : req.url;
  return next(req.clone({ url, setHeaders: cookie ? { cookie } : {} }));
};

Relative URLs must be absolutised on the server — that is what the url rewrite above is for. Pointing at 127.0.0.1 keeps the call inside the box rather than looping out through your load balancer.

Step 5: incremental hydration where it pays

withEventReplay() records clicks and other events that happen before hydration and replays them once the component is live, so early taps are not lost. withIncrementalHydration() goes further: components inside a deferred block stay dormant — no JavaScript downloaded, no hydration — until a trigger fires.

@defer (hydrate on viewport) {
  <app-review-list [productId]="id()" />
} @placeholder {
  <div class="skeleton"></div>
}

@defer (hydrate on interaction) {
  <app-comment-editor [productId]="id()" />
}

@defer (hydrate on idle) {
  <app-related-products [productId]="id()" />
}

The important difference from a plain @defer: with incremental hydration the block is server-rendered, so the content is in the HTML and visible/indexable immediately; only the hydration is deferred. That is the opposite of classic @defer, where the placeholder is what gets rendered.

Where to apply it, in our experience of doing this on client apps:

  • Comment threads, review lists, related-item carousels → hydrate on viewport.
  • Rich text editors, date pickers, chart libraries → hydrate on interaction.
  • Analytics dashboards below the fold → hydrate on idle.
  • Anything that must work instantly (primary nav, add-to-cart, search box) → do not defer.

Use hydrate never for genuinely static rendered markup — a footer, a CMS-driven marketing block — and its JavaScript never ships at all.

Step 6: measure, or you have not finished

Verify with numbers, not vibes:

npx lighthouse https://staging.example.com/product/123 --preset=desktop --view

Look for LCP and TBT before and after, and check the network panel: the initial document should contain your product markup, and the deferred chunks should not download until you scroll. Also watch server-side timing — add a Server-Timing header around angularApp.handle() so you can see render cost per route in the browser's network panel and in your APM.

Then instrument the Node process. SSR converts a front-end problem into a back-end capacity problem: render time, event-loop lag, and Mongoose pool saturation are now your latency budget. If you have already wired OpenTelemetry into your Express 5 API, extend the same trace over the render handler so a slow page and a slow query show up on one timeline.

Step 7: deployment notes

  • Cache what you can. Anonymous, public routes can be cached at the CDN for 60 seconds with stale-while-revalidate; authenticated routes must send Cache-Control: private, no-store. Getting this wrong is how one user's page ends up in another user's browser.
  • Prerender the stable pages. Marketing and docs routes belong in the prerender list in angular.json; there is no reason to render them on every request.
  • Set a render timeout. Wrap the handler so a render that exceeds ~3 seconds falls back to the CSR shell instead of holding a socket open.
  • Two processes minimum, behind a health check that actually renders a route rather than returning a static 200.
  • Keep memory in check. Module-level Map caches in an SSR bundle are shared across every request and leak; if you need caching, use a bounded LRU with a TTL.

The rollout order we use

  1. Add SSR, ship it behind a flag for a single low-traffic route.
  2. Fix browser-global crashes and add the transfer cache.
  3. Move auth to cookies and verify logged-in rendering.
  4. Turn on event replay.
  5. Add incremental hydration block by block, measuring TBT each time.
  6. Expand to the rest of the routes, then set CDN caching rules.

Doing it in that order means every step is independently revertible. Doing all six at once means a bad week.


Need this done on a live app? SSR retrofits are where MEAN projects usually surface every latent assumption about the browser. If you want a second pair of hands — or an audit of whether SSR is even the right fix for your Core Web Vitals problem — get in touch and we will walk your architecture with you.