Route-level Render Modes đŻ
With Angularâs hybrid rendering, every route in your app can be rendered differently: some on the server for every request (SSR), some once at build time (SSG/prerendering), and some only in the browser (CSR). You choose per route in a small file called app.routes.server.ts. This page explains every option in that file.
If you havenât set up SSR yet, start with the SSR Introduction.
What youâll learn
Section titled âWhat youâll learnâ- How server routes relate to your normal Angular routes
- The three render modes â
RenderMode.Server,RenderMode.Prerender,RenderMode.Clientâ and when to pick each - How Angular matches URLs to server routes (wildcards, order, required coverage)
- Prerendering parameterized routes with
getPrerenderParamsandfallback - Setting response headers, status codes and redirects â statically and at runtime
- Using an app shell for client-rendered routes
Two route configs, two jobs
Section titled âTwo route configs, two jobsâYour app now has two route files:
| File | Answers the question | Used by |
|---|---|---|
app.routes.ts (Routes) | Which component shows for this URL? | Browser and server (normal Angular router) |
app.routes.server.ts (ServerRoute[]) | How should the HTML for this URL be produced? | Server and build only |
Server routes donât define components â they only attach a render mode (and optional headers/status) to URL patterns. They are registered in app.config.server.ts:
import { mergeApplicationConfig, ApplicationConfig } 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);The three render modes
Section titled âThe three render modesâRenderMode is an enum exported from @angular/ssr:
| Mode | HTML is produced⌠| Good for | Watch out for |
|---|---|---|---|
RenderMode.Prerender | once, at build time | Pages that are the same for everyone: home, about, blog posts, docs | Data is frozen until the next build; no access to the request |
RenderMode.Server | on every request on the server | Fresh or personalised pages that need SEO: product pages, search | Needs a running server; costs CPU per request |
RenderMode.Client | in the browser (server sends an empty shell) | Dashboards, pages behind login, heavy browser-only UIs | No SEO; content appears after JS loads |
The quick decision guide:
- Is the page identical for every visitor between deployments? â Prerender.
- Does it need to be fresh/personalised and visible to crawlers or fast on first load? â Server.
- Otherwise (private, very interactive, browser APIs everywhere) â Client.
A real-world configuration
Section titled âA real-world configurationâImagine a shop with these Angular routes:
import { Routes } from '@angular/router';
export const routes: Routes = [ { path: '', loadComponent: () => import('./pages/home').then((m) => m.Home) }, { path: 'about', loadComponent: () => import('./pages/about').then((m) => m.About) }, { path: 'blog', loadComponent: () => import('./pages/blog-list').then((m) => m.BlogList) }, { path: 'blog/:slug', loadComponent: () => import('./pages/blog-post').then((m) => m.BlogPost) }, { path: 'products/:id', loadComponent: () => import('./pages/product').then((m) => m.Product) }, { path: 'account', loadComponent: () => import('./pages/account').then((m) => m.Account) }, { path: 'dashboard', loadComponent: () => import('./pages/dashboard').then((m) => m.Dashboard) }, { path: 'old-blog', redirectTo: 'blog', pathMatch: 'full' }, { path: '**', loadComponent: () => import('./pages/not-found').then((m) => m.NotFound) },];A matching server config could be:
import { inject } from '@angular/core';import { PrerenderFallback, RenderMode, ServerRoute } from '@angular/ssr';import { PostService } from './post.service';
export const serverRoutes: ServerRoute[] = [ // Same HTML for everyone â build once (SSG) { path: '', renderMode: RenderMode.Prerender }, { path: 'about', renderMode: RenderMode.Prerender }, { path: 'blog', renderMode: RenderMode.Prerender },
// Parameterized route â tell Angular which slugs to build { path: 'blog/:slug', renderMode: RenderMode.Prerender, async getPrerenderParams() { const posts = inject(PostService); // inject() BEFORE any await const slugs = await posts.getSlugs(); return slugs.map((slug) => ({ slug })); }, fallback: PrerenderFallback.Server, // new posts â SSR on demand (the default) },
// Fresh data on every request â SSR { path: 'products/:id', renderMode: RenderMode.Server, headers: { 'Cache-Control': 'public, max-age=60' }, }, { path: 'account', renderMode: RenderMode.Server, headers: { 'Cache-Control': 'private, no-store' } },
// Browser-only, behind login â CSR { path: 'dashboard', renderMode: RenderMode.Client },
// Permanent redirect on the server { path: 'old-blog', renderMode: RenderMode.Server, status: 301 },
// Everything else â SSR { path: '**', renderMode: RenderMode.Server },];The PostService used above is an ordinary service:
import { Service, inject } from '@angular/core';import { HttpClient } from '@angular/common/http';import { firstValueFrom } from 'rxjs';
@Service()export class PostService { private readonly http = inject(HttpClient);
getSlugs(): Promise<string[]> { return firstValueFrom(this.http.get<string[]>('https://api.example.com/posts/slugs')); }}How URLs are matched
Section titled âHow URLs are matchedâEach ServerRoute has a path and a renderMode. The matching rules:
- No leading slash. Write
'products/:id', not'/products/:id'(the build reports an error). :parammatches exactly one segment, like in the Angular router.*also matches one segment.**matches any number of segments.'**'alone is the catch-all;'docs/**'matches everything under/docs/.- The first matching entry wins. If several server routes match a URL, Angular uses the one defined earliest in the array. Put specific routes first and
'**'last. - Every Angular route must be covered. If a route in
app.routes.tsdoesnât match any server route, the build fails with âroute does not match any route defined in the server routing configurationâ. A final'**'entry is the easiest way to cover everything. - Server routes must exist in the Angular router. A server route that matches no Angular route is also reported as an error, which protects you from typos.
RenderMode.Server (SSR)
Section titled âRenderMode.Server (SSR)âThe page is rendered for every request. This is the only mode where you can read the request (REQUEST, REQUEST_CONTEXT) and change the response at runtime (RESPONSE_INIT) â see Accessing the request and response.
Tips:
- Add
Cache-Controlheaders so a CDN can cache public SSR pages for a short time. Useprivate, no-storefor personalised pages. - Data fetched with
HttpClient/httpResourceis transferred to the browser automatically, so it isnât fetched twice during hydration. - In
ng serve, server-rendered routes behave exactly as in production.
RenderMode.Client (CSR)
Section titled âRenderMode.Client (CSR)âThe server answers with the CSR shell (index.csr.html): your index.html with scripts and styles, but without rendered content. The page then renders in the browser like a classic single-page app. Hydration doesnât apply because thereâs nothing to hydrate.
Use it for routes that:
- are behind authentication (crawlers canât see them anyway),
- depend heavily on browser-only APIs (canvas editors, maps, drag-and-drop dashboards),
- would be expensive to render on the server for no SEO benefit.
App shell for client-rendered routes
Section titled âApp shell for client-rendered routesâAn app shell is a minimal, static layout (header, navigation, loading state) shown immediately for client-rendered routes, while the app loads. Configure it with withAppShell():
import { ApplicationConfig } from '@angular/core';import { provideServerRendering, withAppShell, withRoutes } from '@angular/ssr';import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = { providers: [ provideServerRendering( withRoutes(serverRoutes), withAppShell(() => import('./app-shell').then((m) => m.AppShell)), ), ],};import { Component } from '@angular/core';
@Component({ selector: 'app-shell', template: `<header>My App</header><p class="loading">LoadingâŚ</p>`,})export class AppShell {}withAppShell() accepts a component class or a lazy import() function. The rendered shell replaces the empty index.csr.html, so client-rendered routes show the layout instantly.
RenderMode.Prerender (SSG)
Section titled âRenderMode.Prerender (SSG)âThe page is rendered at build time and saved as a static HTML file (for example dist/my-app/browser/about/index.html). When your Node.js server receives a request for it, Angular serves the file directly â no rendering â with an ETag header, and answers 304 Not Modified when the browser already has the latest version.
A prerendered route cannot set status (the type doesnât allow it) and has no access to the request. It can set headers, which are applied when the Angular server serves the page.
Parameterized routes need getPrerenderParams
Section titled âParameterized routes need getPrerenderParamsâAngular canât guess which :slug values exist, so for a route like blog/:slug you must provide a getPrerenderParams function. It returns a Promise of parameter objects; each object becomes one HTML file:
{ path: 'blog/:slug', renderMode: RenderMode.Prerender, async getPrerenderParams() { return [{ slug: 'hello-angular' }, { slug: 'signals-101' }]; // â /blog/hello-angular and /blog/signals-101 },},Rules for getPrerenderParams:
- It runs at build time only, in Angularâs injection context, so you can call
inject()to use services. - Call
inject()synchronously, before anyawait. After anawaityouâre outside the injection context andinject()throws. - Donât depend on browser APIs or the incoming request â there is no request at build time. Use absolute URLs for HTTP calls.
- Without it, a parameterized
Prerenderroute fails the build with âuses prerendering and includes parameters, but âgetPrerenderParamsâ is missingâ.
Catch-all and multi-parameter routes
Section titled âCatch-all and multi-parameter routesâFor ** segments the parameter name is '**', and its value can contain slashes. You can combine it with normal parameters:
import { PrerenderFallback, RenderMode, ServerRoute } from '@angular/ssr';
export const docsRoutes: ServerRoute[] = [ { path: 'docs/**', renderMode: RenderMode.Prerender, async getPrerenderParams() { return [{ '**': 'getting-started' }, { '**': 'guides/routing' }]; // â /docs/getting-started and /docs/guides/routing }, }, { path: 'shop/:category/:id', renderMode: RenderMode.Prerender, fallback: PrerenderFallback.Client, async getPrerenderParams() { return [ { category: 'books', id: '1' }, { category: 'games', id: '7' }, ]; }, },];Fallback for paths that werenât prerendered
Section titled âFallback for paths that werenât prerenderedâWhen a request arrives for a parameter value you didnât prerender (say, a blog post published after the build), the fallback option decides what happens. Itâs only allowed on routes that have getPrerenderParams.
PrerenderFallback | Behaviour for unknown paths |
|---|---|
Server (default) | Render it with SSR on demand |
Client | Send the CSR shell and render in the browser |
None | No fallback â Angular wonât render paths for this route that werenât prerendered |
Headers and status codes
Section titled âHeaders and status codesâStatic: in the server route
Section titled âStatic: in the server routeâ{ path: 'products/:id', renderMode: RenderMode.Server, headers: { 'Cache-Control': 'public, max-age=60', 'X-Frame-Options': 'DENY' },},{ path: 'legacy-api-docs', renderMode: RenderMode.Server, status: 410, // Gone},headers works for all modes; status works for Server and Client routes.
Dynamic: with RESPONSE_INIT
Section titled âDynamic: with RESPONSE_INITâOften the status depends on data â for example a product that doesnât exist should return 404 so search engines drop it. Inject RESPONSE_INIT (from @angular/core) and change it while rendering:
import { Component, RESPONSE_INIT, computed, effect, inject, input } from '@angular/core';import { httpResource } from '@angular/common/http';
interface Product { id: string; name: string;}
@Component({ selector: 'app-product-page', template: ` @if (product.hasValue()) { <h1>{{ product.value().name }}</h1> } @else if (product.error()) { <h1>Product not found</h1> } `,})export class ProductPage { readonly id = input.required<string>(); // bound from the route with withComponentInputBinding() protected readonly product = httpResource<Product>(() => `/api/products/${this.id()}`); private readonly responseInit = inject(RESPONSE_INIT); private readonly notFound = computed(() => this.product.status() === 'error');
constructor() { effect(() => { if (this.notFound() && this.responseInit) { this.responseInit.status = 404; } }); }}RESPONSE_INIT is null in the browser and during prerendering, hence the if. Your routerâs wildcard NotFound page can use the same trick to return a real 404 (see the SSR Introduction).
Redirects
Section titled âRedirectsâRedirects in your Angular routes (redirectTo) are handled on the server too:
- SSR: Angular sends a real HTTP redirect. The status is
302by default; setstatuson the matching server route to use301,303,307or308. Any other status on a redirect route is a build error. - Prerender: a static file canât send HTTP headers, so Angular writes a small HTML page with a
<meta http-equiv="refresh">âsoft redirectâ. - Guards: if a guard redirects (returns a
UrlTreeorRedirectCommand) during server rendering, the server also answers with an HTTP redirect.
{ path: 'old-blog', redirectTo: 'blog', pathMatch: 'full' },
// app.routes.server.ts{ path: 'old-blog', renderMode: RenderMode.Server, status: 301 },Common mistakes
Section titled âCommon mistakesâ- Leaving the generated
'**' â Prerenderwith parameterized routes. AddgetPrerenderParamsor switch those routes toServer. - Leading slashes in server route paths (
'/about'). Use'about'. '**'placed first. The first match wins, so it would swallow every route. Keep it last.- Calling
inject()afterawaitinsidegetPrerenderParams. - Using
fallbackwithoutgetPrerenderParams. Itâs a type error â fallback only makes sense for parameterized routes. - Personalised data on prerendered pages. A prerendered page is identical for every visitor; use
ServerorClientinstead. - Expecting
REQUEST/RESPONSE_INITinPrerenderorClientroutes. Theyâre only provided forRenderMode.Server.
Key takeaways
Section titled âKey takeawaysâapp.routes.tspicks the component;app.routes.server.tspicks how the HTML is produced.Prerenderfor static pages,Serverfor fresh/personal pages that need SEO,Clientfor private or browser-heavy pages.- Order matters (first match wins) and every Angular route must be covered â end with
'**'. - Parameterized prerendered routes need
getPrerenderParams;fallbackcontrols unknown paths (default: SSR). - Use
headers/statusfor static values andRESPONSE_INITfor dynamic ones; redirects become real HTTP redirects with SSR.
Next steps
Section titled âNext stepsâ- Static Site Generation â prerender everything and deploy to static hosting
- Hydration â what happens in the browser after the HTML arrives
- Incremental Hydration â hydrate
@deferblocks only when needed - Advanced Routing â guards, resolvers and lazy loading