Learn Angular Prerendering Without SSR Using outputMode Static

Oct 9, 2026AngularEngineering
Summarize with AI: Google AI Claude ChatGPT Perplexity Grok

AI links open with a title + excerpt (these tools can't fetch the page themselves) — use "Copy full article" to paste the complete text for a fuller summary.

Share:

Introduction

This article demonstrates how to prerender an Angular application at build time, so every page is plain HTML on a CDN with no Node server running anywhere.

Angular 17 and later give you three ways to render a route — client, server, or prerendered at build time. Most articles cover server-side rendering. We do not run a server, and this article is about that case.

Why No Server

Our site is hosted on Azure Static Web Apps. The frontend is static files on a CDN, and the only backend is an Azure Functions API under /api.

Adding live SSR would mean running a Node process to render pages on demand. For a blog whose content changes when we publish a post — not per request, not per user — that is a server to pay for, monitor and keep warm, for output that is identical every time.

Features of full prerendering

  • Every page is a static file, served from the CDN edge.
  • Search engines get complete HTML, no JavaScript needed.
  • No server to run, scale or patch.
  • The API is only called by the browser after load, and at build time.

The trade-off is that pages are as fresh as your last build. For a blog that is fine. For a dashboard it is not.

With the following steps, you can do the same.

  1. Set the output mode to static.
  2. Prerender the dynamic routes.
  3. Keep the private routes client-side.
  4. Handle the routing fallback.

Set the Output Mode to Static

In frontend/angular.json, in the build options.

"outputMode": "static"

That one setting is the whole decision. There is no ssr block and no server entry — with outputMode: "static", Angular renders every route at build time and produces files only.

The build output lands in dist/frontend/browser. Note that browser sub-folder. It catches people out when configuring a deployment, because the path you build to is not the path you deploy from.

Prerender the Dynamic Routes

A static route is easy — Angular knows /blog exists because it is in your route config.

blog/:slug is the problem. Angular cannot guess your slugs. You have to hand it the list, in app.routes.server.ts.

export const serverRoutes: ServerRoute[] = [
  { path: 'admin/**', renderMode: RenderMode.Client },
  {
    path: 'blog/:slug',
    renderMode: RenderMode.Prerender,
    getPrerenderParams: getBlogSlugParams,
  },
  { path: '**', renderMode: RenderMode.Prerender },
];

getPrerenderParams returns one object per page to generate.

async function getBlogSlugParams(): Promise<Record<string, string>[]> {
  try {
    const res = await fetch(API_URL, { signal: AbortSignal.timeout(10_000) });
    if (!res.ok) return [];
    const { posts } = (await res.json()) as { posts: { slug: string }[] };
    return posts.map((post) => ({ slug: post.slug }));
  } catch {
    return [];
  }
}

Three things here are worth explaining, and all three were learned the hard way.

It fetches from the live API, not the database. You would expect to query Cosmos DB directly at build time — it is the same repository, the connection code is right there. It does not work. Angular's prerender workers run with a stripped environment and do not inherit process.env, so COSMOS_CONNECTION_STRING is undefined inside them. Fetching the public API is the reliable path.

It has a timeout. Node's fetch has no default timeout. A stalled connection here does not throw — it hangs, blocks past Angular's own route-extraction deadline, and kills the whole prerender worker along with every unrelated route it was handling. One AbortSignal.timeout(10_000) turns that into an ordinary error.

The catch returns an empty array, not a throw. If the API is down, you get a build with no blog pages rather than no build at all. That is a deliberate choice and you should decide it consciously — the opposite choice (fail the build) is also defensible, and is probably right if missing pages would be worse than a delayed deploy.

The prerendered output — one HTML file per route

Keep the Private Routes Client-Side

Not every route should be prerendered.

{ path: 'admin/**', renderMode: RenderMode.Client }

Two reasons for the admin section.

There is nothing to prerender. Every admin page sits behind a login. The prerendered HTML would be an empty shell or a login redirect — no SEO value, no faster first paint.

One of them cannot be prerendered. admin/blog/:slug/edit has a route parameter and no getPrerenderParams. Without a list of slugs, Angular does not know what to generate. The build fails or produces nothing useful.

The wildcard admin/** covers the whole section, so an admin page added next year is handled without anybody remembering this file.

Handle the Routing Fallback

A prerendered site still needs the host to serve index.html for paths it does not have a file for. In staticwebapp.config.json:

"navigationFallback": {
  "rewrite": "/index.html",
  "exclude": ["/api/*", "/assets/*", "*.{css,js,ico,png,jpg,jpeg,svg,webp,woff,woff2,webmanifest,json}"]
}

The exclude list is the half that matters. Without it, a missing image returns your HTML page with status 200, and you spend an afternoon wondering why a broken icon "loads successfully".

What This Costs You

The honest limits of a fully prerendered site.

Content is as fresh as your last build. Publish a post and it is live in the database immediately — but its page does not exist until the next deploy. Our blog list fetches from the API at runtime so new posts appear there, while the post's own prerendered page arrives with the next build. Know which parts of your app behave which way.

Nothing is personalised in the HTML. Every visitor receives the same file. Anything user-specific has to happen after hydration.

Auth state is client-side only. No server sees the request, so nothing can render "logged in" HTML. Our admin session is checked after load by calling GET /api/auth/session — and because the token lives in an HttpOnly cookie, JavaScript could not read it anyway.

Build time grows with your content. Every post is a page to render. At 76 posts this is not noticeable. At 10,000 it would be.

Conclusion

In this article we learned how to prerender an entire Angular app with outputMode: "static", how to feed dynamic routes a slug list with getPrerenderParams, why that fetch must have a timeout, and why admin routes should stay RenderMode.Client.

The result is a site with no server in it. For content that changes when you publish rather than per request, that is the simplest thing that works.

Reference

#angular#prerender#ssr#static-site#azure-static-web-apps#seo

Comments

Be the first to comment.

Leave a comment

Never shown publicly.

Comments are reviewed before appearing publicly.