We prerendered our entire component catalog. Here's everything that broke.
Most Angular component libraries quietly break under SSR, and you find out during your own migration. We turned on prerendering for all 121 routes of our docs site, fixed what fell over, and put a blocking CI job in front of it.
"Works with SSR" is the easiest claim in this category to make and the hardest to verify. Nobody checks it until they're three days into a migration, watching localStorage is not defined scroll past in a build log, trying to work out which of forty components in their app is responsible.
So we stopped claiming it and started proving it. Every one of the 121 routes on base-ui.net — the whole component catalog, every demo page, every layout — is now prerendered to static HTML at build time. A blocking CI job fails the build if any route stops rendering.
Turning that on was not a quiet afternoon. Here's what it found.
Angular 21 setup, briefly
// angular.json
"browser": "src/main.ts",
"server": "src/main.server.ts",
"outputMode": "static"
outputMode: "static" prerenders every route to real HTML and needs no Node server at runtime — it deploys to static hosting unchanged. Routes opt out individually when their content isn't enumerable at build time:
export const serverRoutes: ServerRoute[] = [
{
path: 'learn/:slug',
renderMode: RenderMode.Prerender,
getPrerenderParams: async () => ARTICLE_SLUGS.map((slug) => ({ slug })),
},
{ path: '**', renderMode: RenderMode.Prerender },
];
Then we ran the build, and it fell over.
Four assumptions that turned out to be wrong
Most SSR advice reduces to "don't touch window." That's true and insufficient. The harder question is which of your code even runs on the server, and the common intuitions are wrong.
ngAfterViewInit runs on the server. There is no view to attach to, but Angular runs the hook. Two of our layouts constructed an IntersectionObserver there. Both crashed.
ngOnDestroy runs on the server. This is the one that cost us the most. Prerendering destroys the application after serializing each route, so every teardown hook executes. Eight overlay components — popover, mega-menu, custom-select, multi-select, mention-input, phone-input, color-picker, rich-text-editor — called window.removeEventListener in ngOnDestroy. Every one threw.
effect() bodies run on the server. Effects flush during change detection, and change detection runs while rendering. Our bottom sheet locked body scroll from an effect:
effect(() => {
document.body.style.overflow = this.open() ? 'hidden' : '';
});
That's a ReferenceError on the server. The fix isn't a platform guard — it's injecting the document, so the behaviour survives:
private readonly document = inject(DOCUMENT);
effect(() => {
this.document.body.style.overflow = this.open() ? 'hidden' : '';
});
afterNextRender does not run on the server — which makes it the right tool whenever the work is genuinely browser-only, rather than reaching for a platform check.
There was also a subtler class: Angular's server DOM is domino, not a browser. It doesn't implement everything. Our code block component checked el.dataset.highlighted and got Cannot read properties of undefined — the element existed, dataset didn't.
One line, thirty-six broken routes
After the obvious fixes, roughly 36 routes still failed — and the failures moved between builds. phone-input would fail; we'd isolate it, and it prerendered perfectly on its own. Then a different route would fail.
The clue was that prerendering runs many routes through a shared worker process. A throw during one route's teardown kills the worker, and every route queued behind it reports a failure it had nothing to do with. We were reading cascade noise as thirty-six separate bugs.
It was one line, in select-tree:
close(): void {
window.clearTimeout(this.openTimeout); // ← outside the guard
this.isOpen.set(false);
this.onTouched();
if (typeof window !== 'undefined') { // ← guard starts here
window.removeEventListener('scroll', this.scrollListener, true);
}
}
Someone had added the guard for the listener removal and left the clearTimeout above it. ngOnDestroy calls close(). That single unguarded call, firing during teardown, was responsible for every remaining failure.
The technique that found it
Reading code hadn't worked — the scanner we'd written reported zero unguarded render-time paths, because the call was two hops from the lifecycle hook and sat behind a guard that looked like it covered the method.
So we stopped reading and made the runtime tell us. window is undefined on the server, so we defined it as a getter that logs its own stack:
// main.server.ts — temporary diagnostic
Object.defineProperty(globalThis, 'window', {
configurable: true,
get() {
console.error('WINDOW_PROBE ' + new Error('window accessed').stack);
return undefined;
},
});
One build. Every access, with a full stack trace. Filtering out Angular's own internal probes left four frames, two of which were correctly guarded typeof window checks. The other two were the bugs.
If you're debugging SSR in an Angular app, this is worth ten times what static analysis gives you. Note the two-step run: with worker parallelism the real error hides behind cascade noise, so run it serially first —
NG_BUILD_MAX_WORKERS=1 npx ng build my-app --configuration=production
— and the first reported failure is the one that matters.
A browser bug we only found because of SSR
While chasing the above we scanned for components that start a timer with no cleanup path. Nineteen of them did:
constructor() {
setTimeout(() => this.isLoading.set(false), 700);
}
No clearTimeout, no ngOnDestroy, no DestroyRef. That's a leak in a browser too — every instantiation leaves behind a callback that may write to a component that no longer exists, and in a SPA that navigates a lot, they accumulate.
They now go through a small utility scoped to the injection context:
private readonly timers = injectTimers();
constructor() {
// Cancelled automatically if the component is destroyed first.
this.timers.setTimeout(() => this.isLoading.set(false), 700);
}
SSR didn't create that bug. It made it visible.
Why the CI job matters more than the fix
Every one of these was a one-line change. The reason they existed is that nothing was checking, and the reason they won't come back is that now something is:
- name: 🖥️ SSR prerender gate
run: npx ng build base --configuration=production
Prerendering the docs site server-renders the entire catalog without a DOM. A component that reaches for a browser global outside a guard fails that job, on that pull request — not in your application six months from now.
That's the difference between "we fixed our SSR bugs" and "this library is SSR-safe." The first is a state; the second needs an invariant.
What this means if you use Base UI
Components install by being copied into your project — so this isn't a version bump you wait for, it's source you own. Run npx base-ui-cli diff and you'll see these changes as ordinary diffs, and npx base-ui-cli update merges them in, interactively for anything you've customised.
If your app doesn't server-render, every one of these fixes is a no-op: platform guards and injected documents behave identically in a browser. If it does — or if you're prerendering for SEO, which for a marketing or docs surface you probably should be — you're starting from a component layer that's verified on every commit rather than one you'll be debugging yourself.
And the payoff is not abstract. Our data table page used to ship as a shell — correct <title> and meta tags, and a body a crawler had to execute JavaScript to see. It now arrives with the headings, the rendered table, the usage examples and the API reference already in the markup. That's the difference between a page a crawler can read and one it can't — and increasingly the crawlers that matter, the ones behind AI assistants, don't execute JavaScript at all.
Browse the catalog at base-ui.net →
Base UI is an Angular 21 + Tailwind CSS 4 component library. 187 components and blocks, 102 free forever. Pro is a one-time $99 license with lifetime updates.
Start with the free tier
All primitives, all 19 form blocks, and 390 icons — no account, no licence key.
Get started