SEO & metadata
Search engines reward HTML that already says what a page is — and because this app is server-rendered, every crawler gets a complete document on the first response, not an empty shell waiting for JavaScript. The sections below set a real per-route title, a full set of meta tags, structured data, and a canonical URL. Then they read those values back out of the live document, so you can see exactly what shipped.
Per-route <title> & meta tags
Why it matters: the title is the single biggest on-page signal and the clickable headline in results; the description and Open Graph / Twitter tags decide how a link looks when it's shared. Setting them per route — via Angular's Title and Meta services so they render on the server — means each page presents itself correctly instead of inheriting a generic site-wide default. This panel reads the values straight back from the DOM, so it shows what was actually set, not what it was meant to set.
| Tag | Source | Live value |
|---|---|---|
| Title | document.title | SEO & metadata · Enterprise Angular Showcase — Miguel Carino |
| Description | meta[name=description] | How a server-rendered Angular app earns its place in search: per-route titles and meta tags, JSON-LD structured data, canonical URLs, and the sitemap/robots story. |
| og:title | meta[property=og:title] | SEO & metadata · Enterprise Angular Showcase — Miguel Carino |
| og:type | meta[property=og:type] | website |
| og:url | meta[property=og:url] | https://demo.miguelcarino.com/seo |
| twitter:card | meta[name=twitter:card] | summary_large_image |
View source — the app-wide MetadataStrategymetadata.strategy.ts
This page sets its tags by hand to make the mechanism visible, but the app doesn't repeat that per route. A single TitleStrategy runs on every navigation: it composes the title from the route's title, reads the description off the leaf route's data, and writes the description, Open Graph / Twitter, robots, and canonical tags — all through the SSR-safe Title/Meta services so they land in the first HTML response.
import { DOCUMENT } from '@angular/common';
import { Injectable, inject } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
import { type RouterStateSnapshot, TitleStrategy } from '@angular/router';
// The production origin — canonical URLs and Open Graph tags point here.
const SITE_ORIGIN = 'https://demo.miguelcarino.com';
const SITE_NAME = 'Enterprise Angular Showcase';
const AUTHOR = 'Miguel Carino';
/*
* A `TitleStrategy` that does the whole per-route metadata job on every navigation: the
* document title (from the route's `title`), the meta description, the canonical URL, and
* Open Graph / Twitter card tags (from the route's `data.description`). The /seo page
* demonstrates the technique in isolation; this applies it app-wide so every route a blog
* post links is correct in a tab and shares cleanly as a preview card.
*
* SSR-safe: `Title`/`Meta` render into the prerendered head on the server, and the canonical
* link is written through `DOCUMENT` (which Angular provides server-side too), so the tags
* are present in the static HTML, not only after hydration.
*/
@Injectable({ providedIn: 'root' })
export class MetadataStrategy extends TitleStrategy {
private readonly title = inject(Title);
private readonly meta = inject(Meta);
private readonly document = inject(DOCUMENT);
override updateTitle(snapshot: RouterStateSnapshot): void {
/*
* Compose the full document title from the route's page name and the site brand, so every
* tab and search result reads "<Page> · Enterprise Angular Showcase — Miguel Carino".
*/
const page = this.buildTitle(snapshot);
const title = page ? `${page} · ${SITE_NAME} — ${AUTHOR}` : `${SITE_NAME} — ${AUTHOR}`;
this.title.setTitle(title);
const description = this.deepestData(snapshot)['description'] as string | undefined;
const url = `${SITE_ORIGIN}${snapshot.url.split(/[?#]/)[0]}`;
if (description) {
this.meta.updateTag({ name: 'description', content: description });
this.meta.updateTag({ property: 'og:description', content: description });
this.meta.updateTag({ name: 'twitter:description', content: description });
}
this.meta.updateTag({ property: 'og:title', content: title });
this.meta.updateTag({ property: 'og:type', content: 'website' });
this.meta.updateTag({ property: 'og:site_name', content: SITE_NAME });
this.meta.updateTag({ property: 'og:url', content: url });
this.meta.updateTag({ name: 'twitter:card', content: 'summary_large_image' });
this.meta.updateTag({ name: 'twitter:title', content: title });
/*
* Every demo route is unique interactive content meant to be found — so the whole of demo.
* is index,follow. The thin surfaces (the federated remotes, Storybook) opt out on their own.
*/
this.meta.updateTag({ name: 'robots', content: 'index, follow' });
this.setCanonical(url);
}
// The metadata lives on the leaf route, so walk to the deepest activated snapshot.
private deepestData(snapshot: RouterStateSnapshot): Record<string, unknown> {
let route = snapshot.root;
while (route.firstChild) {
route = route.firstChild;
}
return route.data;
}
private setCanonical(url: string): void {
const head = this.document.head;
if (!head) {
return;
}
let link = head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!link) {
link = this.document.createElement('link');
link.setAttribute('rel', 'canonical');
head.appendChild(link);
}
link.setAttribute('href', url);
}
}
JSON-LD structured data
Why it matters: structured data lets you describe the page in a vocabulary search engines understand (schema.org), which is what powers rich results — breadcrumbs, sitelinks, knowledge panels. The page injects a <script type="application/ld+json"> into the document head holding a small graph (WebSite, WebPage, and a BreadcrumbList), and removes it again on destroy so it never leaks into the next route. Here is the exact JSON that was emitted:
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"name": "Enterprise Angular Showcase by Miguel Carino",
"url": "https://demo.miguelcarino.com"
},
{
"@type": "WebPage",
"name": "SEO & metadata · Enterprise Angular Showcase — Miguel Carino",
"description": "How a server-rendered Angular app earns its place in search: per-route titles and meta tags, JSON-LD structured data, canonical URLs, and the sitemap/robots story.",
"url": "https://demo.miguelcarino.com/seo",
"isPartOf": {
"@type": "WebSite",
"url": "https://demo.miguelcarino.com"
}
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://demo.miguelcarino.com"
},
{
"@type": "ListItem",
"position": 2,
"name": "SEO & Metadata",
"item": "https://demo.miguelcarino.com/seo"
}
]
}
]
}Canonical URL
Why it matters: the same content is often reachable at several URLs — tracking params, trailing slashes, http vs https, paginated variants. Left alone, a crawler treats those as duplicates and splits ranking signals across them. A <link rel="canonical"> names the one URL that should be indexed, so all the authority consolidates there. The page queries for an existing tag before adding one, so re-entering the route updates it in place rather than stacking duplicates.
link[rel=canonical] →https://demo.miguelcarino.com/seo
Sitemap & robots
Why it matters: robots.txt and sitemap.xml are how you tell crawlers what to look at and what to ignore. In an SSR Angular app these are best served as real routes (or static files) at the origin root — a server handler can generate sitemap.xml from the same route table the app already owns, so the map can never drift from what actually ships.
User-agent: *
Allow: /
Sitemap: https://demo.miguelcarino.com/sitemap.xml The staging trap to avoid: a non-production build that a crawler reaches will compete with your real site. The fix is to flip a flag by environment — emit a blanket noindex everywhere a build is not production:
User-agent: *
Disallow: / Per-page, the same intent is expressed with <meta name="robots" content="noindex, nofollow"> — which, because it is server-rendered, is present in the very first response a crawler sees.