Angular can render a page in three places, and since Angular 19 it can decide where per route. That is hybrid rendering. The route table itself takes ten minutes to learn. What takes longer is finding out what changes around a page once it is prerendered: its data stops updating, its request disappears, and your host starts serving it before your server gets a say. This article covers both halves, the constructs first and the consequences after.

Three places to render a page

On the client, the server answers with an empty shell, the browser downloads the bundle and builds the page from nothing. That is the single-page application as designed, and it is still right for anything that only makes sense once the user is known.

On the server, each request renders the page to HTML before it leaves. The visitor and the crawler get content on the first byte, and the server can do what the browser should not: hold a secret, hit a database, cache the result for everyone.

At build time, the page is rendered once, in CI, and the output is a file. No server runs for it ever again. This is the mode that pays for itself fastest, because on most sites a surprising share of pages changes only when someone deploys, and rendering those on demand is paying for the same work on every cache miss.

Hybrid rendering is choosing one of the three per route instead of per application.

Reference: https://angular.dev/guide/hybrid-rendering

The route table

Server routes are declared next to the client routes and handed to provideServerRendering. In Angular 19 this was a separate provideServerRouting() provider; it now lives inside provideServerRendering():

TypeScriptbootstrapApplication(AppComponent, { providers: [provideServerRendering(withRoutes(SERVER_ROUTES))], });

Each entry names a path and a renderMode:

app.routes.server.tsTypeScriptexport const SERVER_ROUTES: ServerRoute[] = [ { path: 'pricing', renderMode: RenderMode.Prerender }, { path: 'contact', renderMode: RenderMode.Prerender }, { path: 'blog/:slug', renderMode: RenderMode.Server }, { path: 'account/**', renderMode: RenderMode.Client }, { path: '**', renderMode: RenderMode.Server }, ];

Pricing and contact change with a deploy, so they become files. An article is published by an editor without a deploy, so it is rendered per request and cached. The account area needs the user, so the server sends the shell and the client takes over. Everything else falls to the server by default.

Three rules the table does not enforce for you:

  • The wildcard entry is required. A route no entry matches defaults to Prerender, and the engine refuses to prerender at request time, so without the ** entry an unknown URL fails instead of reaching your 404 page.

  • Matching is by specificity, not array order. The exact paths win wherever they sit; the catch-all is last only for reading.

  • A localised path is its own route. If the locale is a path segment (/en/pricing, /cs/pricing), each language needs an entry. A helper that maps a page over the supported languages keeps the table honest:

TypeScriptconst localised = (page: string): ServerRoute[] => SUPPORTED_LANGUAGES.map(lang => ({ path: `${lang}/${page}`, renderMode: RenderMode.Prerender, })); export const SERVER_ROUTES: ServerRoute[] = [ ...PRERENDERED_PAGES.flatMap(localised), { path: '**', renderMode: RenderMode.Server }, ];

Useful constructs

Parameters and a fallback

A route with a parameter can be prerendered if the build can enumerate the values. getPrerenderParams runs in the injection context of the build, so it can use your services:

TypeScript{ path: 'product/:id', renderMode: RenderMode.Prerender, async getPrerenderParams() { const products = inject(ProductsService); const top = await products.topSellers(); return top.map(product => ({ id: product.id })); }, fallback: PrerenderFallback.Server, }

fallback decides what happens to an id the build did not render. PrerenderFallback.Server renders it on request, Client sends the empty shell, None answers 404. The combination above is the common case for a catalogue: the top sellers are files, the long tail stays dynamic. A catch-all parameter works too: path: 'docs/**' with params like { '**': 'guide/routing' }.

Status and headers

Any entry can carry status and headers. For a static status code that is far simpler than reaching the response through the RESPONSE_INIT token:

TypeScript{ path: 'page-not-found', renderMode: RenderMode.Server, status: 404 }, { path: 'old-pricing', renderMode: RenderMode.Server, status: 410 }, { path: 'preview/**', renderMode: RenderMode.Server, headers: { 'X-Robots-Tag': 'noindex' }, },

On a prerendered route these apply only when the Angular engine serves the file. A host that serves it as a static asset never sees them, which comes up again below.

Client mode as the escape hatch

RenderMode.Client is for pages that make no sense without the user: a dashboard behind a login, an account page, an editor. It is also the way out for a component that cannot run in Node at all (a canvas library, a WebGL chart). The alternative is to keep the route server-rendered and defer the one component:

HTML@defer (on viewport; hydrate on viewport) { <app-globe-chart [data]="data()" /> } @placeholder { <app-chart-skeleton /> }

@defer keeps the component out of the server render, and the hydrate trigger (incremental hydration, enabled with withIncrementalHydration()) keeps it out of the client bootstrap too, until it scrolls into view.

Static output mode

outputMode: 'server' in the builder options is what emits the app-engine manifest for the modes above. outputMode: 'static' prerenders every route and ships no server at all, which is the right choice for a site with nothing dynamic and no API behind it: a documentation site, a portfolio, a launch page.

What breaks once a page is prerendered

Everything above is a route table. The rest of this article is what the route table does to a page, and it is where most of the production surprises live.

The data is frozen, and not just until hydration

A prerender runs the page’s data fetching during the build, with whatever the API answers at that moment, and the result is frozen until the next build. That part is expected. What is less expected is that the freeze does not end when the client takes over.

That gives the rule for which pages qualify: prerender a page when its content changes only with a deploy.

If a page must be prerendered and still show fresh data after load, exclude its requests from the transfer cache rather than fighting the cache policy:

TypeScriptprovideClientHydration( withEventReplay(), withHttpTransferCacheOptions({ filter: req => !req.url.includes('/api/stock'), }), ),

The excluded request runs again on the client, and the page pays one round trip for it.

There is no request

The canonical URL is the usual victim. Reading it from REQUEST.url works per request; on a prerender it is null, and a fallback to the bare origin quietly declares every prerendered page a copy of the homepage. PlatformLocation is initialised from the render URL on both paths, so it is the fallback that works:

TypeScriptprivate renderUrl(): { pathname: string; search: string } { return this.request ? new URL(this.request.url) : this.platformLocation; }

Gate the build

So the build needs a gate of its own, after the prerender. A script that walks every prerendered file, not a sample:

  • the file for every expected route exists, and there are exactly as many as the route table predicts;

  • the HTML contains none of the markers that mean the render failed (the element selector of the error component, of the 404 page, an empty <router-outlet>);

  • the transfer state is present and carries the data the page fetches for itself.

The last check wants to be per page, not page-wide. A layout that runs a query on every route (a menu, a footer, a related-items strip) populates the cache on every page, so “the cache is not empty” passes even when the page’s own section rendered empty. Name the query each page must have answered, and allow the ones that may legitimately be empty (a careers page with no open position).

The gate should also complain about a stale snapshot: a route removed from the table leaves its file in dist when the output path is not cleaned, and the host keeps serving it.

The host serves the file before your server runs

Most hosts answer from the static output before they consider a rewrite to a function: Firebase Hosting does, so do Cloudflare and Vercel. A prerendered page is a file there, so for that URL nothing in your Express server runs. Not the security-headers middleware, not the canonical-host redirect, not the cache-control logic, not any ?nocache escape hatch you built, and not the headers from the route table either.

Headers are the first casualty. If the document headers (X-Frame-Options, the CSP, Referrer-Policy, Permissions-Policy) are Express middleware, the prerendered pages ship without them. They have to be declared in the host’s configuration too, and then the two copies have to stay identical: a host applies its rules on top of a function response as well, so a header set in both places with different values is a conflict, not a fallback. Keep one source: a constants file the server imports, and a test that compares the host config against it.

JSON{ "source": "**/!(*.*)", "headers": [ { "key": "X-Frame-Options", "value": "SAMEORIGIN" }, { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" } ] }

Your own static middleware

The Express server still serves the same files locally and on any request that reaches the function. A prerendered page is a directory on disk (pricing/index.html), and serve-static’s defaults redirect /pricing to /pricing/ the moment that directory exists. If the server also has a trailing-slash redirect, the two bounce the visitor back and forth. index: false, redirect: false ends the loop and leaves the engine to serve the file:

TypeScriptserver.use( express.static(browserDistFolder, { fallthrough: true, index: false, redirect: false, }), );

Caching the routes that stay dynamic

Prerendering only moves the static pages off the server. The per-request routes still need a cache policy, and the rendered status is the right thing to key it on: a long shared-cache lifetime for a 2xx, a short one for a rendered 404 (the usual reason for one is a slug that is about to be published), none at all for a redirect that depends on Accept-Language, none for a 5xx so the next visitor retries. Set it after the engine has answered, because that is when the status is known.

Between the two modes sits one Angular does not have natively: a page that changes a few times a day. The nearest thing is a per-request route behind a CDN with s-maxage and stale-while-revalidate, which costs one render per lifetime per edge, or a scheduled rebuild. Pick the cheaper of the two for the page’s change rate rather than forcing it into the prerender list.

Checklist

  1. Prerender a route only if its content changes with a deploy. The transfer state freezes fetched data for the session, not just the first paint.

  2. Enumerate parameters with getPrerenderParams and choose a fallback for the values the build did not see.

  3. One entry per locale, Client mode for pages that need the user, @defer for the one component that cannot run in Node, and a ** entry in Server mode.

  4. Nothing in a prerendered render may depend on REQUEST or RESPONSE_INIT. Fall back to PlatformLocation for the URL.

  5. Gate the build: walk every prerendered file, fail on error markers and missing data, count the files.

  6. Skip the build cache on deploys.

  7. Repeat the document headers in the host configuration, matched against clean URLs, without a Cache-Control.

  8. index: false, redirect: false on your own static middleware.

  9. Key the per-request cache lifetime on the rendered status.