Migrate to RunxBuild today!! Deploy More while Spending Less

Calculate your savings
unxBuild

SSG in Angular: Prerendering With @angular/ssr, Step by Step

Sean

Platform Writer

Sep 30, 2026
10 min read

SSG in Angular is prerendering: run ng add @angular/ssr, tell the build which routes to render, and ng build writes a static HTML file for each route into dist, ready for any static host with no Node server involved.

SSG in Angular: Prerendering With @angular/ssr, Step by Step

Angular has done server rendering for years, but the static side used to be a separate tool with its own config. Since the application builder arrived, prerendering is part of the normal build, and in recent versions it is controlled by two things: an option in angular.json and a small server routes file. This guide covers both, how to render dynamic routes such as blog posts, what hydration does after the HTML loads, and the browser-only code that breaks prerendering more often than anything else. It ends at the part people skip, which is where the output goes.

Table of contents

What prerendering actually gives you

A default Angular app is a client-rendered single-page app. The server sends an almost empty HTML shell, the browser downloads JavaScript, and only then does content appear. Search crawlers can cope with that, but slowly and inconsistently, and users on slow phones stare at a blank screen for the duration of the download.

Prerendering runs your app in Node at build time, once per route, and saves the resulting HTML. The visitor gets real content in the first response. Angular then downloads the JavaScript and hydrates the page, attaching event listeners to the existing DOM rather than rebuilding it. Static site generation is the name for this when every route is done ahead of time and nothing renders on request.

The trade is straightforward. You get fast first paint, good crawlability and a deployment that is just files. You give up per-request data: a prerendered page shows what was true at build time, so a price list or a stock counter needs either a rebuild or a client-side fetch after hydration. That is why SSG suits marketing pages, docs and blogs, and why an authenticated dashboard usually does not bother.

The three rendering modes Angular now offers per route are Prerender (build time), Server (per request, needs Node) and Client (browser only). You can mix them in one app, and that mix is configured in the same routes file we set up below.

Setting it up with ng add @angular/ssr

For a new project the shortcut is the SSR flag on ng new, which asks about server rendering and prerendering during setup. For an existing project, add the package.

ng add @angular/ssr

The schematic installs the packages, adds a server entry point and a server routes file, and updates your app config with the server rendering provider. It also turns on hydration in the browser config, which is worth checking in app.config.ts.

// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideClientHydration(withEventReplay())
  ]
};

withEventReplay records clicks and inputs that happen after the HTML arrives but before hydration finishes, then replays them, so a fast user is not ignored. It is a small addition and worth keeping.

Versions matter here, so check yours. The server routes file and render modes arrived in Angular 19 and were refined in 20. Older projects on 17 or 18 use a prerender option in angular.json alone, which still works and is covered next. If the generated files differ from what you read in a tutorial, the version gap is almost certainly the reason.

Configuring prerender in angular.json

The switch lives in the build target of angular.json, under the application builder. Setting prerender to true renders every route the builder can discover from your router config. You can also pass an object to control discovery and to supply a routes file.

{
  "projects": {
    "my-app": {
      "architect": {
        "build": {
          "builder": "@angular/build:application",
          "options": {
            "outputPath": "dist/my-app",
            "browser": "src/main.ts",
            "server": "src/main.server.ts",
            "prerender": {
              "discoverRoutes": true,
              "routesFile": "routes.txt"
            },
            "outputMode": "static"
          }
        }
      }
    }
  }
}

discoverRoutes tells the builder to follow your static router paths. routesFile points at a plain text file with one route per line, which is where you list anything discovery cannot infer, such as pages generated from data. The outputMode value of static tells Angular the result is only static files, so it skips producing a deployable server bundle. Set it to server instead if any route renders per request.

/
/about
/pricing
/blog/first-post
/blog/second-post

Run ng build and look in dist/my-app/browser. Each route has its own folder with an index.html inside it, so /about is served from about/index.html. Open one and view source: the content is there in the markup. If it is not, the route was not prerendered and you are looking at the client shell.

Dynamic routes and getPrerenderParams

Static paths are easy. The interesting case is a route like blog/:slug, where the builder cannot know which slugs exist. In current Angular versions you describe that in the server routes file, and getPrerenderParams returns the parameter sets to render.

// src/app/app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
import { inject } from '@angular/core';
import { PostService } from './post.service';

export const serverRoutes: ServerRoute[] = [
  { path: '', renderMode: RenderMode.Prerender },
  {
    path: 'blog/:slug',
    renderMode: RenderMode.Prerender,
    async getPrerenderParams() {
      const posts = inject(PostService);
      const slugs = await posts.getAllSlugs();
      return slugs.map((slug) => ({ slug }));
    }
  },
  { path: 'account/**', renderMode: RenderMode.Client },
  { path: '**', renderMode: RenderMode.Prerender }
];

Each object in the returned array becomes one prerendered page, so returning fifty slugs produces fifty HTML files. Because the function runs at build time in Node, it can read from a file, a CMS or a database, but the source has to be reachable from your build environment. A build that works on your laptop and fails in CI often means the data source was only reachable locally.

Notice the account route marked Client. Logged-in areas have nothing useful to prerender, and rendering them at build time would try to run code that expects a user. Marking them Client keeps the shell and skips the work. The catch-all at the bottom applies to anything not matched above, so order matters, and specific routes go first.

If you are on Angular 17 or 18, the equivalent is a routes file generated by a script that lists every dynamic URL, which you then reference from the prerender option. It is less elegant, but the output is identical.

Hydration, and the code that breaks prerendering

Hydration is the reason SSG in Angular feels finished. Without it, Angular would throw away the prerendered DOM and draw the page again, causing a flash and wasting the work. With provideClientHydration, it reuses the DOM the server produced. It only works if the DOM the browser expects matches the DOM in the HTML, and mismatches are logged in the console with the offending node.

The most common cause of both build failures and hydration errors is code that assumes a browser. Prerendering runs in Node, where window, document and localStorage do not exist, so a component that reads them in a constructor throws during the build with a ReferenceError.

import { Component, afterNextRender } from '@angular/core';

@Component({ selector: 'app-theme', template: '<p>{{ theme }}</p>' })
export class ThemeComponent {
  theme = 'light';
  constructor() {
    afterNextRender(() => {
      this.theme = localStorage.getItem('theme') ?? 'light';
    });
  }
}

afterNextRender only runs in the browser, after the first render, which is exactly where browser-only work belongs. For older code, check isPlatformBrowser with the PLATFORM_ID token. Third-party libraries that touch window on import need a dynamic import inside afterNextRender, or a lazy load that only fires on the client.

The other classic trap is content that differs between build and browser: dates rendered with the current time, random ids, or anything reading the viewport. Pin them to values that are stable across both, or defer them until after hydration.

Hosting the prerendered output

With outputMode set to static, the whole deployable artifact is dist/my-app/browser. Upload that folder to any static host and you are done. There is no Node process to babysit, no cold starts, and nothing to patch on a server, which is the entire point of choosing SSG.

The details that catch people are on the hosting side. Every prerendered route should be served from its own index.html with clean URLs. Routes you did not prerender, especially the Client ones, need a fallback so a deep link to an account page returns the app shell instead of a 404. A static host that supports rewrites or an SPA fallback handles it in one rule. Cache headers matter too: hashed JavaScript files can be cached for a year, while the HTML files should revalidate so a new deploy shows up immediately.

If any route uses RenderMode.Server, the output is no longer just files. You need a Node service running the server bundle, which is a different deployment with a process to keep alive and a port to listen on. Decide early, because it changes the hosting bill. The Angular deployment walkthrough covers the client-only case, and the ng build configuration guide explains the builder options in more detail. The official Angular SSR and hybrid rendering guide is the reference for the server routes API.

How this fits the rest of the stack

For a fully prerendered Angular site, the hosting decision is easy: a static site with the build command set to npm run build and the publish directory set to the browser folder inside dist. Add the fallback rule for your client-rendered routes and you are finished. The RunxBuild hosting calculator will show what a static site costs against a Node service, so you can see the price of keeping one server-rendered route before you commit to it. To deploy, connect your repository in the RunxBuild dashboard and read the static build configuration page for the settings, and the SPA fallback page for the deep-link rule. If a build fails on a browser global, the deploy logs show the exact component, which beats guessing from a local run that happened to pass.

Useful related references:

FAQ

What is SSG in Angular?

Static site generation in Angular means prerendering routes to HTML files at build time using @angular/ssr. The builder runs your app in Node for each route and saves the output, so visitors get real content immediately and the site can be served from any static host without a running server.

What is the difference between SSR and SSG in Angular?

SSR renders a page on the server for each request, so it needs a running Node process and can show fresh data. SSG renders once at build time and serves fixed files. SSG is cheaper and faster to host, but the content is only as fresh as your last build.

How do I prerender dynamic routes in Angular?

Define the route in your server routes file with RenderMode.Prerender and a getPrerenderParams function that returns the parameter values, such as an array of slugs. Angular renders one page per returned object. On older versions, list each URL in a routes file referenced by the prerender option in angular.json.

Why does my Angular prerender build fail with window is not defined?

Prerendering runs in Node, where browser globals like window, document and localStorage do not exist. Move that code into afterNextRender, guard it with isPlatformBrowser, or load the library lazily on the client. The build error usually names the component that touched the global.

Where does Angular put the prerendered files?

In the output path you configured, inside a browser folder, for example dist/my-app/browser. Each prerendered route gets its own directory containing an index.html. With outputMode set to static, that folder is the only thing you need to deploy.

#ssg angular#Angular prerender#@angular/ssr#hydration#static hosting