# Choosing a render mode per route

Open this when mapping routes to render modes, prerendering a large parameterised route, or deciding cache headers and deployment shape. Verified 2026-09 against https://angular.dev/guide/ssr.

## 1. The decision, route by route

| The content is… | Mode | Why |
|---|---|---|
| Identical for everyone, changes on deploy | `RenderMode.Prerender` | Built once, served from a CDN, zero server cost, fastest possible TTFB |
| Identical for everyone but changes more often than you deploy | `RenderMode.Server` + a short `Cache-Control` | The CDN still does most of the work; the server refreshes it |
| Depends on the request (query, geo, A/B, locale from a header) | `RenderMode.Server` | Cannot be known at build time |
| Personalised or behind authentication | `RenderMode.Client` | Nothing to index, and server-rendering per user costs money for no gain |
| An app shell around a client-rendered area | `RenderMode.Client` + `withAppShell(AppShell)` | The shell is prerendered so first paint is instant; the route fills in |

Write the table for your app before touching the config. The common mistake is one mode for the whole app: a marketing site with a logged-in dashboard wants all three.

## 2. `getPrerenderParams`

```ts
{
  path: 'products/:category/:slug',
  renderMode: RenderMode.Prerender,
  async getPrerenderParams() {
    const products: { category: string; slug: string }[] =
      await fetch('https://api.example.com/products?fields=category,slug').then((r) => r.json());
    return products.map(({ category, slug }) => ({ category, slug }));
  },
}
```

- Returns one object per generated page, with a key for **every** parameter in the path. A missing key means that page is not generated.
- It runs at **build time**, so the build now depends on that API being up. Cache the response in CI, or fail the build loudly rather than shipping a site missing half its pages.
- Ten thousand products means ten thousand HTML files and a long build. Above roughly a thousand pages, prerender the top slice by traffic and let `fallback: PrerenderFallback.Server` render the long tail on demand.
- A catch-all `path: '**'` cannot be prerendered; give it `RenderMode.Server` or `RenderMode.Client`.

**`fallback`** decides what happens to a path the params did not cover:

| Value | Behaviour | Use when |
|---|---|---|
| `PrerenderFallback.Server` (default) | Render on demand on the server | A long tail exists and should still be indexable |
| `PrerenderFallback.Client` | Ship the shell, fetch on the client | The long tail does not need SEO |
| `PrerenderFallback.None` | 404 | The prerendered set is exhaustive by definition |

## 3. Headers and status per route

```ts
{
  path: 'articles/:slug',
  renderMode: RenderMode.Server,
  headers: {
    'Cache-Control': 'public, max-age=60, stale-while-revalidate=600',
    'X-Content-Type-Options': 'nosniff',
  },
},
{ path: 'gone/:id', renderMode: RenderMode.Server, status: 410 },
```

`stale-while-revalidate` is what makes server rendering cheap: the CDN keeps serving the old HTML while it refreshes in the background, so a traffic spike hits the CDN rather than your Node process.

**Never make a personalised server-rendered route publicly cacheable.** `Cache-Control: private, no-store` on anything that varies per user. The transfer cache embeds fetched data in that HTML, so a shared cache entry leaks it — this is the highest-severity mistake in this whole area.

## 4. `outputMode`

```json
// angular.json → architect → build → options
"outputMode": "server"
```

- `"server"` (the default): a Node server handles requests; prerendered routes are still served as static files from `dist/<app>/browser`.
- `"static"`: no server is produced. Only valid when every route is `Prerender` or `Client`. This is the cheapest deployment there is — object storage plus a CDN — and worth checking for before accepting a server.

## 5. `server.ts`

`ng add @angular/ssr` generates it; you edit it only to add your own middleware.

```ts
import { AngularNodeAppEngine, writeResponseToNodeResponse } from '@angular/ssr/node';
import express from 'express';

const app = express();
const angularApp = new AngularNodeAppEngine();

app.use('*', (req, res, next) => {
  angularApp
    .handle(req)
    .then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
    .catch(next);
});
```

Things that belong here: health checks, security headers, a request-id, compression, a reverse-proxy trust setting. Things that do not: business logic, database access, or an API — those belong in your actual backend. An SSR server that grows endpoints becomes a second backend nobody planned to own.

For other runtimes there are equivalents (`AngularAppEngine` for edge/web-standard request handling); the shape is identical.

## 6. Deployment shape

| Mode | Deploy to | Watch |
|---|---|---|
| `"static"` | Object storage + CDN | Rebuild and redeploy whenever prerendered content changes |
| `"server"` | Node container or a managed Node host, behind a CDN | Memory per instance, cold starts, and a CDN configured to respect your per-route `Cache-Control` |

Server rendering adds an always-on process to your operational surface: it can be down, it can be slow, it can leak memory, and it needs the same logging, alerting and deploy pipeline as any other service. If the only reason for it is a marketing page, prerender that page and keep the app client-rendered.

## 7. When SSR is the wrong answer

- **Behind a login.** Nothing indexes it; the user already waited for auth; you gained a server and lost simplicity.
- **An internal tool.** Nobody is on a cold 3G connection looking at your admin panel.
- **"For performance", with no measurement.** SSR improves first contentful paint and LCP; it does nothing for interaction latency, and a poorly configured transfer cache can make the total loading work worse.
- **To fix a slow API.** The server calls the same API. Server rendering moves the wait, it does not remove it — and now the user sees a blank browser tab rather than a skeleton.
