The report came in as a screenshot of a blank share card. Someone had posted a link to a product page, and the preview was empty — no title, no description, no image, just a naked URL. It wasn't one page, either. Once I looked, half the app was setting its <title> in an ngOnInit, the other half wasn't setting one at all, three pages shared the literal title "App", and not a single one wrote an Open Graph tag. Every page was reinventing metadata, badly, in isolation.
The instinct is to fix it page by page: add the missing setTitle here, sprinkle in some Meta calls there. I did a few that way before I saw the shape of the problem. Metadata wasn't missing because people were careless. It was missing because it had no home — it lived wherever each component author remembered to put it, which meant it lived nowhere consistent, and nothing could enforce it.
Metadata scattered in components has no owner
A title set in a component's lifecycle hook is invisible to every other part of the system. Nothing can tell you which routes have one and which don't; nothing can guarantee a new page ships with the right tags; there's no single place that knows the rule "every page has a unique title and description." The knowledge is smeared across dozens of components, each holding one-thirteenth of a policy nobody wrote down.
And it fails exactly where it matters most. A title set in ngOnInit runs in the browser, after JavaScript executes. The social crawler that generated that blank card fetched the server's HTML, saw the placeholder <title>, and left — it was never going to wait for Angular to boot and patch the DOM. So the per-component approach wasn't just disorganized; for the crawlers that consume metadata, it often wasn't running at all.
Metadata is route data, applied in one place, rendered on the server
The fix that held was to stop treating metadata as a component's job and start treating it as a property of the route. Each route declares its own title and description; one TitleStrategy reads them on every navigation and applies the tags. The policy now lives in exactly one place, and a route without metadata is a visible gap in a config file instead of an invisible omission in a component.
// routes carry their own metadata: `title` for the tab, `data` for the rest
{
path: 'reports/:id',
loadComponent: () => import('./report.component'),
title: reportTitleResolver, // string | ResolveFn<string> — dynamic per entity
data: { description: 'A saved report and its charts.' },
}
// one TitleStrategy applies title + meta on every navigation, in one place
@Injectable({ providedIn: 'root' })
export class MetadataStrategy extends TitleStrategy {
private readonly title = inject(Title);
private readonly meta = inject(Meta);
override updateTitle(state: RouterStateSnapshot): void {
const page = this.buildTitle(state); // resolves the route's `title`
this.title.setTitle(page ? `${page} · My Site` : 'My Site');
let route = state.root;
while (route.firstChild) route = route.firstChild;
const description = route.data['description'];
this.meta.updateTag({ name: 'description', content: description });
this.meta.updateTag({ property: 'og:title', content: page ?? 'My Site' });
}
}
// wired once, app-wide: { provide: TitleStrategy, useClass: MetadataStrategy }
For pages whose title depends on loaded content — a report's name, a product's title — a resolver produces the metadata before the route activates, so the tag is correct on first render rather than patched in after a fetch. And the whole thing runs under SSR (or prerendering), so the server sends HTML that already carries the right <title>, description, canonical, and Open Graph tags. The crawler that got a blank card now gets a complete one, because the metadata exists in the response instead of being applied a second too late.
Centralizing it this way also made room for the tags that are genuinely structural: a canonical link per route so duplicate URLs don't split ranking, and JSON-LD describing the page's entity for search engines that read it. Those belong to the route too, and once the routing layer owns metadata, adding them is one more field, not one more scattered call.
There's a heavier alternative worth naming: drive all of this from a CMS, so non-engineers edit titles and descriptions without a deploy. That's the right call when marketing owns the copy and iterates on it constantly — but it's a content-infrastructure project, and it was overkill for an app whose metadata is mostly derived from the data already on the page. Route-data-plus-resolver gave me consistency and correct server rendering without standing up a CMS; if editorial control becomes the need later, the seam is already there, because everything reads from one place. The other alternative — leave it in components but be more disciplined — I'd already watched fail. Discipline isn't a mechanism, and the crawler doesn't grade on effort.
Metadata Is a Routing Concern, Not a Component Afterthought
The blank share card wasn't a missing setTitle — it was metadata with no owner, applied too late for the machines that read it. Moving it into the routing layer turned a scattered habit into a single enforceable policy: every route declares its title and description as data, one service applies them, resolvers handle the dynamic cases, and the server renders the result so crawlers see it on the first byte. That's what people mean when they say metadata "becomes architecture." It stops being something each page remembers to do and becomes something the system does for every page, the same way, every time.


