This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
The angular-paul desktop clone had a working SSR server that rendered nothing. Every route was set to client-only, so a crawler hitting /thoughts/signals got an empty <app-root> and the one genuinely shareable thing in the app — the written content — was invisible. This is the pass that gave each route the render mode it actually needs: the Thoughts pages prerender to static HTML at build time, and the interactive shell stays client-rendered.
The app already had an SSR server standing up — Angular’s server build, the whole hydration pipeline, all of it. But every entry in the server route config was RenderMode.Client, which tells the server to ship the shell and defer everything to the browser. So the SSR server was doing real work to render… a blank app root. I only noticed how bad it was when I ran curl against a Thoughts URL and got back markup with no essay in it. Nothing to index, nothing in a link preview, nothing for anyone who reads before JavaScript runs.
The easy overcorrection is to flip the whole app to SSR and call it fixed. That’s wrong here. The desktop shell — the windows, the dock, the menu bar, the drag state — is deeply interactive and inherently client-side; server-rendering it buys nothing and invites hydration mismatches. The Thoughts content is the opposite: static prose that never changes between requests and is the entire reason SEO matters. So the right unit of decision is the route, not the app.
I gave /thoughts and /thoughts/:slug RenderMode.Prerender, and left the catch-all ** desktop route on RenderMode.Client. For the parameterised route I added a getPrerenderParams() that enumerates every slug straight from the THOUGHTS data, so the build emits one static HTML file per thought — and adding a thought later automatically adds its prerendered page, with nothing to remember.
Prerendering the body is only half of it. A crawler wants a real <title>, a description, Open Graph and Twitter card tags, a canonical link, and structured data — all present in the server HTML before any script runs. I pulled that into a SeoService that writes per-page title, description, keywords, OG and Twitter meta, the canonical <link>, and a JSON-LD BlogPosting block, all through Angular’s Title, Meta, and DOCUMENT so it renders on the server rather than being patched in on the client. One service, called per page, instead of head tags scattered across templates.
The moment the shell actually server-renders, latent hydration problems surface. Two showed up. The clock ran a setInterval unconditionally, which doesn’t belong on the server — I guarded it with isPlatformBrowser so the timer only starts in the browser. And the menu bar’s markup diverged between the server pass and the first client render, so I put ngSkipHydration on it: an honest acknowledgement that it’s a client-owned island, rather than fighting to make its server output match. Both are the kind of thing you only find once real SSR is switched on.
I wrote this the TDD way — 8 new specs first, failing, covering the SeoService output, the prerender route config and its param enumeration, and the component wiring — then made them green. The full suite is at 266 passing and lint is clean. The measurable proof is in the build itself: ng build reports Prerendered 20 static routes, and dist/…/thoughts/signals/index.html contains the essay body, a real <title>, the og: tags, the canonical URL, and the application/ld+json block — all before a line of JavaScript executes. I grepped the built HTML directly to confirm it, rather than trusting the build log. Version went 1.1.0 → 1.2.0 with a changelog entry, and the render-mode strategy itself is now one of the prerendered thought pages.
The build emits a soft budget warning — the initial bundle is 416.97 kB against a 400 kB warn threshold, still under the 500 kB hard error. The long thought bodies are the weight, and the obvious follow-up is to move them into a lazily loaded data chunk so the eager bundle stops carrying prose it doesn’t need on first paint. Not urgent, but it’s the first place a real budget problem would show up.