Static Site Generation in Angular
By Syncfusion®, Inc. · Last updated
Generating HTML files at build time: prerendering static and parameterized routes, build-time data, deployment, and a Documentation Website capstone project.
Introduction to Static Site Generation
SSR renders a page for every request. But many pages are the same for every visitor and change rarely, for example documentation, a company's "About" page or a blog post that never changes.
For these pages, rendering on every request is wasted work. You can render them once and send the same finished file to every visitor.
What is Static Site Generation (SSG)?
In Static Site Generation (SSG), Angular renders your pages when you build the app with ng build. For each route, it saves the HTML in a file, for example docs/routing/index.html.
A static file is a file that does not change between requests. A file hosting service sends these files to the browser, and no server code runs. In the browser, the page is hydrated, the same as with SSR.
SSG is also called prerendering.
Real-World Example
A software product has 300 documentation pages that change only when a new version is released. When the team runs ng build, Angular creates one HTML file for each page.
A file hosting service sends the files to readers around the world. Each page opens immediately, and no server needs to render anything.
ng buildoncePrerenderevery routeHTML filesdocs/…/index.htmlFile hostsends the filesThe pages are created once. Every visitor gets the same ready file.
Static Site Generation: the HTML is created at build time.
Benefits of SSG
- Fastest response. The HTML is ready, so the host only sends a file.
- Cheap and simple hosting. Any file hosting service works. No Node.js server is needed.
- Full HTML for search engines, the same as SSR.
- Very reliable. No server code runs, so there is nothing that can crash during a request.
SSG Architecture
Build-Time Rendering
Build time is the moment when you run ng build. Request time is the moment when a user opens a page. SSR renders at request time. SSG renders at build time.
Static HTML Generation
During the build, Angular runs your app once for each route. It renders the components, including data from HttpClient, and saves the result as an HTML file.
Pre-rendered Pages
The result is a folder of ready files in dist/<project-name>/browser:
dist/docs-site/browser/
├── index.html ← the home page
├── docs/
│ ├── installation/index.html ← /docs/installation
│ ├── components/index.html ← /docs/components
│ └── routing/index.html ← /docs/routing
├── main-XYZ.js
└── styles-XYZ.css
| Point | CSR | SSR | SSG |
|---|---|---|---|
| HTML created | In the browser | On the server, for each request | Once, at build time |
| First view | Slow | Fast | Fastest |
| Data | Always current | Always current | From the last build |
| Hosting | File host | Node.js server | File host |
SSG Rendering Fundamentals
Prerendering
You turn on SSG with the same @angular/ssr package as SSR (ng new --ssr or ng add @angular/ssr). Then you choose RenderMode.Prerender for the routes in app.routes.server.ts:
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{ path: '**', renderMode: RenderMode.Prerender },
];
When all routes are prerendered, you do not need a server. Set "outputMode": "static" in angular.json, and ng build creates only static files:
"build": {
"options": {
"outputMode": "static"
}
}
Static Route Prerendering
A static route has a fixed path, such as '' or about. Angular knows its URL, so it prerenders it automatically.
Parameterized Route Prerendering
In the app itself, a parameterized route is a normal route with :slug. One component displays every page:
getPrerenderParams()returns the slugs{ slug: "installation" }docs/installation/index.html{ slug: "components" }docs/components/index.html{ slug: "routing" }docs/routing/index.html
One object from getPrerenderParams() becomes one generated page.
One route for many pages
import { Component, Input } from '@angular/core';
import { RouterLink, RouterOutlet, Routes } from '@angular/router';
const TITLES: Record<string, string> = {
installation: 'Installation',
components: 'Components',
routing: 'Routing',
};
@Component({
selector: 'app-doc-page',
template: `<p>Page: <b>{{ title }}</b> (slug: {{ slugText }})</p>`,
})
export class DocPage {
title = '';
slugText = '';
@Input() set slug(value: string) {
this.slugText = value;
this.title = TITLES[value] ?? 'Not found';
}
}
export const routes: Routes = [
{ path: '', redirectTo: 'docs/installation', pathMatch: 'full' },
{ path: 'docs/:slug', component: DocPage },
];
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink],
template: `
<a routerLink="/docs/installation">Installation</a> |
<a routerLink="/docs/components">Components</a> |
<a routerLink="/docs/routing">Routing</a>
<router-outlet />
`,
})
export class App {}
For SSG, Angular must know the three slugs before the build. That is the job of getPrerenderParams().
A parameterized route has a route parameter, such as docs/:slug. Angular does not know which slugs exist, so you must tell it with getPrerenderParams(). This function returns one object for each page to generate:
export const serverRoutes: ServerRoute[] = [
{
path: 'docs/:slug',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
return [{ slug: 'installation' }, { slug: 'components' }, { slug: 'routing' }];
},
},
{ path: '**', renderMode: RenderMode.Prerender },
];
Angular then generates /docs/installation, /docs/components and /docs/routing.
Build-Time Route Generation
Writing the slugs by hand is easy to forget. Generate them from your content instead. The function runs at build time, so it can read your content list or request an API:
async getPrerenderParams() {
return DOCS.map(doc => ({ slug: doc.slug }));
}
The next example shows the same idea in the browser: it creates the list of paths from the content.
Generating the list of pages from the content
import { Component } from '@angular/core';
const DOCS = [
{ slug: 'installation', title: 'Installation' },
{ slug: 'components', title: 'Components' },
{ slug: 'routing', title: 'Routing' },
];
// Like getPrerenderParams(): one object for each page
function prerenderParams() {
return DOCS.map(doc => ({ slug: doc.slug }));
}
@Component({
selector: 'app-root',
template: `
<p>ng build will generate these pages:</p>
@for (params of pages; track params.slug) {
<p>/docs/{{ params.slug }} → docs/{{ params.slug }}/index.html</p>
}
`,
})
export class App {
pages = prerenderParams();
}
Add an object to DOCS and click Run: the new page appears in the list.
A parameterized route without getPrerenderParams() is not prerendered. Users who open its URL directly get no generated page.
Content Generation
Build-Time Data Fetching
HttpClient requests in your components run during the build, and their answers are saved in the HTML. So the page contains the data that the API had at build time.
The next example loads the list of pages with HttpClient. With SSG, this request runs during ng build, and the list is saved in the HTML.
Data loaded for the generated page
import { ApplicationConfig, Component, inject, signal } from '@angular/core';
import { HttpClient, HttpHandlerFn, HttpRequest, HttpResponse, provideHttpClient, withInterceptors } from '@angular/common/http';
import { delay, of } from 'rxjs';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withInterceptors([practiceServer]))],
};
@Component({
selector: 'app-root',
template: `
<h4>What's new in version 2.0</h4>
@for (item of items(); track $index) {
<p>• {{ item }}</p>
}
`,
})
export class App {
items = signal<string[]>([]);
constructor() {
inject(HttpClient)
.get<string[]>('/api/release-notes')
.subscribe(list => this.items.set(list));
}
}
// ─── Practice server ──────────────────────────────────────────────
// Answers the request inside the browser, like in the API Integration lesson.
function practiceServer(req: HttpRequest<unknown>, next: HttpHandlerFn) {
const notes = ['A new search page', 'Faster builds', 'Dark mode'];
return of(new HttpResponse({ status: 200, body: notes })).pipe(delay(300));
}
If the release notes change after the build, the generated page still shows the old list until the next build.
Static Content Management
A simple way to manage the content of a static site is a TypeScript file with the content as data. Components read this data, and the build turns it into pages.
// content/docs.ts
export const DOCS: DocPage[] = [
{ slug: 'installation', category: 'Getting Started', title: 'Installation', paragraphs: ['…'] },
{ slug: 'routing', category: 'Guides', title: 'Routing', paragraphs: ['…'] },
];
To change a page, you edit the data and build again. Larger sites keep the content in Markdown files or a content management system (an app where editors write the content), and read it in the same way during the build.
Content grouped by category
import { Component } from '@angular/core';
const CATEGORIES = ['Getting Started', 'Guides', 'Reference'];
const DOCS = [
{ slug: 'installation', category: 'Getting Started', title: 'Installation' },
{ slug: 'first-project', category: 'Getting Started', title: 'Your First Project' },
{ slug: 'components', category: 'Guides', title: 'Components' },
{ slug: 'routing', category: 'Guides', title: 'Routing' },
{ slug: 'cli-commands', category: 'Reference', title: 'CLI Commands' },
];
@Component({
selector: 'app-root',
template: `
@for (category of categories; track category) {
<p><b>{{ category }}</b></p>
@for (doc of docsIn(category); track doc.slug) {
<p>• {{ doc.title }}</p>
}
}
`,
})
export class App {
categories = CATEGORIES;
docsIn(category: string) {
return DOCS.filter(doc => doc.category === category);
}
}
Deployment and Delivery
Static Assets
Static assets are all the files in dist/<project-name>/browser: the HTML pages, the JavaScript, the CSS, images and fonts. To publish the site, you upload this folder. Nothing else is needed.
Before you upload, test the generated files on your computer with a simple static server, not with ng serve. ng serve renders pages during development, so it does not show you the files that the build created.
Content Distribution
Static files can be copied to many servers around the world. Such a network is called a CDN (Content Delivery Network). Each reader gets the files from a server near them, so pages open fast everywhere. Most file hosting services use a CDN automatically.
Updating Generated Pages
The generated pages do not change by themselves. To publish new or changed content:
Edit contentdocs.tsng buildrenders all pagesUploaddist/.../browserUsers see itnew HTML filesThe pages change only after a new build and upload.
Updating a static site: edit, build, upload.
- Change the content, for example in
docs.ts. - Run
ng buildagain. - Upload the new
browserfolder.
Many teams let a build service do these steps automatically each time the content changes.
You can mix render modes in one app. For example, use RenderMode.Prerender for documentation pages, RenderMode.Server for a search page, and RenderMode.Client (CSR) for a page behind a login. This needs a Node.js server again, because some routes are rendered at request time.
Advantages and Limitations of SSG
Advantages:
- The fastest pages, because the HTML is ready.
- Cheap, simple and reliable hosting on any file host.
- Full HTML for search engines and link previews.
Limitations:
- Content is only as new as the last build. Changes need a new build.
- Long builds for very large sites. Every page is rendered during the build.
- Not for personal pages. A page that is different for each user, such as "My orders", cannot be generated in advance.
| Use SSG for | Use SSR or CSR for |
|---|---|
| Documentation, blogs, marketing pages | Pages that change every minute |
| Content that changes with releases | Pages that are different for each user |
Capstone Project: Documentation Website
You now build a Documentation Website that is generated at build time.
Create Documentation Pages
Each page is an object in DOCS with a slug, a category, a title and its paragraphs. DocPage displays one page.
Generate Pages at Build Time
In the downloadable project, getPrerenderParams() returns one slug for each object in DOCS, and outputMode is static.
Implement Navigation
A side menu shows a routerLink for each page. routerLinkActive highlights the open page.
Organize Content Categories
The menu groups the pages by category with docsIn(category).
Build Reusable Layouts
DocsLayout contains the side menu and a <router-outlet />. Every documentation page is a child route, so all pages share the same layout.
Configure Static Routes
The routes are '' (home), docs with the child route :slug, and a redirect from docs to the first page.
Capstone: Documentation Website
import { Component, Input } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet, Routes } from '@angular/router';
// ── Content ──
export interface Doc {
slug: string;
category: string;
title: string;
text: string;
}
const CATEGORIES = ['Getting Started', 'Guides'];
const DOCS: Doc[] = [
{ slug: 'installation', category: 'Getting Started', title: 'Installation', text: 'Install Node.js and run npm install.' },
{ slug: 'first-project', category: 'Getting Started', title: 'First Project', text: 'Run ng new my-app and ng serve.' },
{ slug: 'components', category: 'Guides', title: 'Components', text: 'A component controls one part of the screen.' },
{ slug: 'routing', category: 'Guides', title: 'Routing', text: 'Routing connects each URL to one screen.' },
];
// ── One documentation page ──
@Component({
selector: 'app-doc-page',
template: `
@if (doc) {
<h4>{{ doc.title }}</h4>
<p>{{ doc.text }}</p>
} @else {
<p>Page not found.</p>
}
`,
})
export class DocPage {
doc: Doc | undefined;
@Input() set slug(value: string) {
this.doc = DOCS.find(d => d.slug === value);
}
}
// ── Reusable layout ──
@Component({
selector: 'app-docs-layout',
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<div style="display: flex; gap: 24px">
<nav>
@for (category of categories; track category) {
<p><b>{{ category }}</b></p>
@for (doc of docsIn(category); track doc.slug) {
<p><a [routerLink]="['/docs', doc.slug]" routerLinkActive="active">{{ doc.title }}</a></p>
}
}
</nav>
<section><router-outlet /></section>
</div>
`,
styles: `.active { font-weight: bold; color: #c3002f; }`,
})
export class DocsLayout {
categories = CATEGORIES;
docsIn(category: string) {
return DOCS.filter(d => d.category === category);
}
}
// ── Routes ──
export const routes: Routes = [
{ path: '', redirectTo: 'docs', pathMatch: 'full' },
{
path: 'docs',
component: DocsLayout,
children: [
{ path: '', redirectTo: 'installation', pathMatch: 'full' },
{ path: ':slug', component: DocPage },
],
},
];
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: `<b>📘 Docs Site</b><router-outlet />`,
})
export class App {}
Click the pages in the menu. In the downloadable project, each of these pages is a separate HTML file.
Download Project Files
Download SSG Project Source Code
Download angular-docs-site-ssg.zip
The ZIP file contains the Documentation Website as a complete Angular project with outputMode: "static".
| File | What it contains |
|---|---|
src/app/content/docs.ts | All pages and categories |
src/app/app.routes.server.ts | RenderMode.Prerender and getPrerenderParams() |
src/app/layout/docs-layout.ts | The reusable layout with the side menu |
src/app/pages/doc-page.ts | One documentation page |
scripts/serve-static.mjs | A small Node.js server that sends the generated files |
Project Setup Instructions
You need Node.js 20.19 or newer.
- Unzip the file and open a terminal in the
docs-sitefolder. Install the packages:
npm installGenerate the pages:
npm run buildThe output ends with "Prerendered 7 static routes". The pages are in
dist/docs-site/browser.Send the generated files with the included static server:
npm run serve:static- Open
http://localhost:8080.
To check SSG, open dist/docs-site/browser/docs/routing/index.html in an editor: it already contains the text of the page. During development, you can also use npm start.
Common Mistakes
| Mistake | What you see | Fix |
|---|---|---|
A parameterized route without getPrerenderParams() | Its pages are missing from the build | Return one object per page from getPrerenderParams() |
| Writing the slugs by hand | New pages are forgotten | Generate them from the content with map() |
| Expecting new content without a new build | The site still shows the old text | Run ng build again and upload the files |
| Using SSG for pages that are different for each user | Every user sees the same generated page | Use CSR or SSR for those routes |
| Browser-only code in components | The build fails, because it renders on the build machine | Use afterNextRender(), as with SSR |
Check Yourself
Exercises
What to Remember
- SSG renders each route once, at build time, and saves the HTML as files. Turn it on with
RenderMode.Prerender, and useoutputMode: "static"when no route needs a server. - Parameterized routes need
getPrerenderParams(). Generate the parameters from your content, so new pages are included automatically. - SSG gives the fastest pages and the simplest hosting, but content changes need a new build, and personal pages need CSR or SSR.
Next: User Access Control, how to log users in and decide which pages each user may open.