I pasted a link to one of my articles into a chat, and the preview came up blank — or worse than blank. The little unfurled card showed the site's generic name and a stock image, the same as every other page on the site, with none of the article's own title or summary. I checked the page in a browser and everything was correct: the tab title was right, the description was right, the social image was right. So I opened the page source that a crawler would actually receive, and there was nothing there. An empty shell, a script tag, and a promise that the real content would arrive once JavaScript ran.
That was the whole bug, sitting in plain sight. My metadata was real. It just didn't exist yet at the moment anyone reading the link needed it.
Two kinds of crawler, one wrong assumption
The assumption I'd been coasting on was that setting a page's title and meta tags at runtime is enough, because that's what I could see working in the browser. It isn't, and the reason splits neatly in two.
Search crawlers like Googlebot do run JavaScript — but not in the same breath as fetching the page. Google's own documentation describes three separate phases: it crawls the HTML, puts the page in a queue to be rendered later when its resources allow, and only then indexes what the rendered page contains. That render can happen seconds after the crawl, or considerably longer. So a client-rendered page isn't invisible to Google, but its content shows up on a delay and on someone else's schedule, and Google is explicit that there are limits to how much script it will execute.
Social and messaging crawlers — the ones that build the link previews on LinkedIn, Facebook, X, Slack, and Discord — are simpler and less forgiving. They make a single request, read the Open Graph tags out of the raw HTML that comes back, and leave. They do not run your JavaScript at all. Google says the quiet part plainly in its own guidance: server-side or pre-rendering is a good idea because not all bots can run JavaScript. My blank preview card was exactly that sentence playing out. The unfurler asked for the page, got the empty shell, found no og:title, and fell back to the site-wide defaults.
Publishing is a build-time decision
The fix is to stop treating "rendered" as something that happens in the visitor's browser and start treating it as something that happens before the file is ever served. For a blog, where every reader gets the same article, that means prerendering: Angular runs each route at build time and writes a fully-formed HTML file, metadata and all, that a static host can hand straight to a crawler.
Angular's rendering is configured per route through a RenderMode — Server renders fresh on every request, Client is the plain SPA, and Prerender generates a static file at build time. For a set of posts you point getPrerenderParams at the list of slugs, and each one becomes its own document:
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: 'writing/:slug',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
const posts: PostSource = inject(PostSource); // inject() must run before any await
const slugs = await posts.publishedSlugs();
return slugs.map((slug) => ({ slug })); // → one static file per post
},
},
{ path: '**', renderMode: RenderMode.Prerender },
];
Pair that with outputMode: 'static' in the build and Angular emits no server bundle at all — just a tree of HTML files, one per route, deployable to any static host or CDN with no Node process anywhere. The one caveat the docs are firm about: call inject() synchronously, before the first await, or it loses its injection context.
What matters is what lands in each file's <head>, because that is the only thing a single-fetch crawler ever sees. The per-route title and description, a canonical URL, the Open Graph tags, the Twitter Card tags, and the JSON-LD block are all baked in before the file leaves the build:
<title>The Store I Reached For and Didn't Need — Miguel Carino</title>
<meta name="description" content="Defaulting to the heaviest state tool cost more…">
<link rel="canonical" href="https://miguelcarino.com/writing/the-store-i-reached-for">
<meta property="og:type" content="article">
<meta property="og:title" content="The Store I Reached For and Didn't Need">
<meta property="og:url" content="https://miguelcarino.com/writing/the-store-i-reached-for">
<meta property="og:image" content="https://miguelcarino.com/og/the-store-i-reached-for.jpg">
<meta name="twitter:card" content="summary_large_image">
A detail that bites people: Open Graph tags use the property attribute while Twitter's use name, and getting that wrong makes the tag invisible to the crawler that wanted it. The same care applies to the JSON-LD Article or BlogPosting block — give each author its own object with a name and a url, rather than mashing names into one string, and use ISO-8601 dates with a timezone.
When prerendering isn't the answer
Prerendering is the right call here precisely because a blog post is the same for everyone and known ahead of time. That's not always true, so it's worth being honest about the alternatives.
Runtime server-side rendering — RenderMode.Server — is what you want when a page depends on who's asking: a dashboard, an account page, anything personalized. It produces the same crawler-ready HTML, but it needs a live Node (or edge) runtime executing Angular on every request, which is real hosting cost and operational surface you don't take on lightly. Edge rendering is the same idea moved closer to the user for latency, with the same authoring constraints. And the approach I started with — setting tags at runtime with Angular's Meta and Title services — isn't wrong so much as misplaced: those services are genuinely useful, but only put metadata in the raw HTML when they run on the server during SSR or prerendering. Run them client-side and you're back to the empty shell. The trap isn't the API; it's the assumption that the browser is the audience.
The Crawler Reads the File, Not the App
The lesson I keep from that blank card is that a crawler doesn't visit your app — it reads a file. Whatever your JavaScript would eventually do is irrelevant to the tool making a single request and parsing what comes back. Once I framed it that way, "make the page shareable" stopped being an SEO chore bolted on at the end and became a build-time property I could see in the output: open the generated file, and either the title and the og:image are sitting right there in the <head>, or they aren't. Prerendering is how I make sure they always are.
You can see the pieces that have to be present — the SSR foundation, per-route title and meta, canonical, and the JSON-LD block — laid out with their source on the SEO demo.


