

Static Angular SSR: Nice URLs and good SEO - you can't have both (but here's a fix!)
I love Angular. But when it comes to prerendered static sites, Angular is surprisingly far behind other major frameworks. Astro, Next.js, SvelteKit: they all let you choose how your pages are written to disk. Angular doesn't.
And static sites are great: hosting is literally free, and a CDN serves your pages to the whole world and scales like a beast. That's why the websites of Angular.Schule and the Angular book are prerendered static sites.
But here comes the catch! Prerender your app, deploy it to a static host (for example GitHub Pages, Cloudflare Pages or Firebase Hosting), and take a look at the network tab: every direct visit starts with a redirect to the same URL with a trailing slash, and a moment later the Angular router quietly removes the slash again. It looks great in the address bar, but technically, for search engine optimization, it's a nightmare. You can get rid of the redirect, but only by putting a trailing slash on every URL of your site. Nice URLs or good SEO: with Angular's prerendering, you can't have both. In this article, I explain why, and how to get both today.
Contents
- Today: nice URLs or good SEO, you can't have both
- Why does Angular do this?
- How do other frameworks handle this?
- With
@angular-schule/prerender-format: nice URLs and good SEO, we deserve both! - The real fix: an option in the Angular CLI
- Two things to watch out for
- Conclusion
Today: nice URLs or good SEO, you can't have both
Angular's prerendering (SSG) writes every route into its own folder.
Take the article you are reading right now: with Angular's default, the route blog/2026-09-static-angular-ssr-trailing-slash becomes blog/2026-09-static-angular-ssr-trailing-slash/index.html.
For the rest of this article, let's call it blog/my-article.
A static host like GitHub Pages or Cloudflare Pages sees a request for /blog/my-article, finds a folder with that name and redirects to /blog/my-article/.
$ curl -I https://example.com/blog/my-article
HTTP/2 301
location: https://example.com/blog/my-article/
The browser follows the redirect, gets the index.html, Angular starts, and the router normalizes the URL back to /blog/my-article.
So you have to choose:
- Nice URLs, but redirects: Your links use
/blog/my-article. Every direct visit (a search engine, a bookmark, a link shared on social media) starts with a redirect to/blog/my-article/, and the Angular router then removes the trailing slash again. The URL in your links is never the URL that actually answers. - No redirects, but trailing slashes everywhere: Your links have to use
/blog/my-article/. Pages answer directly, but every URL ends with a slash, and Angular needs an extra provider to keep it in the address bar:{ provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy }(seeTrailingSlashPathLocationStrategy).
Why does Angular do this?
The file name is decided in exactly one place in the Angular CLI, the function getRouteOutPath() in @angular/build:
function getRouteOutPath(route: string, baseHrefPathname: string): string {
const routeWithoutBaseHref = getRouteWithoutBaseHref(route, baseHrefPathname);
return stripLeadingSlash(posix.join(routeWithoutBaseHref, 'index.html'));
}
Every route ends up as <route>/index.html.
There is no option to change that.
This layout works on every web server, which makes it a good default.
But many static hosts can do better: they serve blog/my-article.html under /blog/my-article, directly, without a redirect.
Angular just never writes blog/my-article.html.
How do other frameworks handle this?
Static site generators have offered this choice for a long time. Angular is the odd one out:
| Framework | Option | blog/my-article/index.html |
blog/my-article.html |
|---|---|---|---|
| Astro | build.format |
'directory' (default) |
'file' |
| Next.js (static export) | trailingSlash |
true |
false (default) |
| SvelteKit | trailingSlash |
'always' |
'never' (default) |
| Nuxt 2 | generate.subFolders |
true (default) |
false |
| Hugo | uglyURLs |
false (default) |
true |
| Angular | – | always | – |
Next.js and SvelteKit even write blog/my-article.html by default.
And the Astro documentation recommends build.format: 'file' together with trailingSlash: 'never', which is the combination Angular can't produce today.
With @angular-schule/prerender-format: nice URLs and good SEO, we deserve both!
The fix is surprisingly small: write blog/my-article.html instead of blog/my-article/index.html.
Until Angular can do this on its own, the builder @angular-schule/prerender-format can do the job.
In fact, you are looking at it right now.
The Angular.Schule website is built with it, and this article is served from blog/2026-09-static-angular-ssr-trailing-slash.html.
Open the network tab and reload: no redirect, just a clean URL.
- Nice URLs:
/blog/my-article, without a trailing slash, in your links, in the address bar and in the server response alike. - Good SEO: every page answers directly. Search engines see no redirect, and the URL they crawl is the same one your links point to.
And yes, blog.html and the folder blog/ can live side by side: the host serves /blog from the file and /blog/my-article from the folder.
Installation
ng add @angular-schule/prerender-format
ng build
ng add swaps the builder of your build target and sets the new option prerenderFormat:
"build": {
"builder": "@angular-schule/prerender-format:application",
"options": {
"outputMode": "static",
"prerenderFormat": "file"
}
}
"directory"(default):/blog/my-articleis written toblog/my-article/index.html, as today."file":/blog/my-articleis written toblog/my-article.html.
I borrowed the terminology from Astro: build.format with 'directory' and 'file' became prerenderFormat with the same values.
All other options stay as they are, the builder passes them on to @angular/build:application.
The option is made for static builds, by design.
An ssr entry is fine as long as outputMode is "static": Angular then uses it only during ng build to prerender the pages, and no server is deployed.
If your build deploys a server ("outputMode": "server", or ssr without outputMode), the option is not considered: you get a warning, and the pages are written to <route>/index.html as before.
With a server, there is nothing to fix anyway: the Node.js server generated by Angular serves static files with redirect: false, so /blog/my-article is never redirected to /blog/my-article/ in the first place.
How it works
The builder calls buildApplication() from @angular/build and replaces the internal function prerenderPages() at runtime, to rename the files before they are written.
That's a hack.
It relies on internal APIs, which is why I narrowed it down to Angular 22 only.
But it's a well-tested hack: it writes the same files as my patch for the Angular CLI, and it runs on the websites of Angular.Schule and the Angular book.
I hope I don't have to maintain this package longer than necessary, because I have also prepared a sustainable fix.
The real fix: an option in the Angular CLI
A built-in option for the Angular CLI is the clean solution. My colleague Ferdinand Malcher opened an issue for this back in 2024: angular/angular-cli#29173, "SSG/Prerendering: Allow generating foo.html instead of foo/index.html". It didn't collect enough votes in the community voting process back then. That's a pity. Let's hope the problem gets more attention now.
So I wrote the pull request myself: angular/angular-cli#34180 adds the same option prerenderFormat to Angular's own application builder:
"build": {
"builder": "@angular/build:application",
"options": {
"outputMode": "static",
"prerenderFormat": "file"
}
}
Once it lands, you only switch the builder name back to @angular/build:application.
That's the plan.
If you want this in Angular, please give the issue a 👍. Upvoting the pull request could be helpful, too! :-) The Angular team decides based on community votes.
Two things to watch out for
A route called index
With 'file', a route /index would be written to index.html and overwrite your start page.
A route /blog/index would become blog/index.html, which web servers serve for /blog/, not for /blog/index.
In this case, builder and pull request keep index/index.html for that route and show a warning.
The same happens when a route would take over a file the build itself uses, such as index.csr.html, or a file name that another route already uses.
The option never makes your build fail.
Previewing the build locally
ng serve renders your pages on the fly and never reads the files in dist/.
To see exactly what your static host will serve (for example several locales under one origin), you need a small server for the dist folder.
If you write it with Express, watch out: with blog.html next to the folder blog/, express.static finds the folder first and redirects /blog to /blog/.
Add a small middleware in front of it that sends blog.html directly:
app.use((req, res, next) => {
if (req.path.endsWith('/') || path.extname(req.path)) { return next(); }
const file = path.join(distFolder, decodeURIComponent(req.path) + '.html');
if (file.startsWith(distFolder) && fs.existsSync(file)) { return res.sendFile(file); }
next();
});
app.use(express.static(distFolder));
Conclusion
Angular's prerendering writes every route into a folder, and static hosts answer that with a redirect to a trailing slash. Today, you have to choose between nice URLs and a site without redirects.
We deserve both.
Write <route>.html instead of <route>/index.html, and static hosts serve your pages directly under clean URLs.
Until Angular supports this natively, two commands are enough. ng add updates your angular.json (new builder, prerenderFormat: "file"), and ng build writes the files:
ng add @angular-schule/prerender-format
ng build
And if you'd like to see this in Angular itself, please vote for the issue and the pull request. 🚀
Further Reading:
- angular/angular-cli#29173: SSG/Prerendering: Allow generating foo.html instead of foo/index.html
- angular/angular-cli#34180: feat(@angular/build): add
prerenderFormatoption @angular-schule/prerender-formaton npm and on GitHub- Astro:
build.format - Angular: Server-side and hybrid rendering
- Angular:
TrailingSlashPathLocationStrategy
Header image: photo from pxhere (CC0), Angular logo by Google (CC BY 4.0).
Keywords:AngularAngular 22SSRSSGPrerenderingStatic Site GenerationoutputMode staticTrailing SlashRedirectSEOGitHub PagesCloudflare PagesAstro build.formatprerenderFormatTrailingSlashPathLocationStrategy
Anregungen? Feedback? Fehler?

About Johannes Hoppe
Johannes Hoppe is a trainer, consultant and developer specializing in Angular. He is a co-author of the Angular book (in German language), together with Ferdinand Malcher and Danny Koppenhagen. After four successful editions, they have rewritten the book from scratch – with modern syntax, compact and covering many new topics. The new book is out now. More info at angular-buch.com