SEO and structured data primer
The content-modelling primer covers ICompositionSeo — the mixin that gives a page MetaTitle, MetaDescription, OgImage, Robots, and CustomSchema. This primer picks up from there: what happens to those fields between an editor filling them in and a <meta> tag or JSON-LD <script> landing in the rendered page. The whole pipeline funnels through one service and one view, which makes it a short primer — the point is mostly to save you from having to trace it yourself the first time.
Just want the tag inventory? Skip to OpenGraph, Twitter Cards, and the Sessionize deep-link override.
The pipeline in one picture
Layout.cshtml
↓ @await Component.InvokeAsync("MetaTags")
MetaTagsViewComponent — thin: resolves currentPage, delegates, no logic of its own
↓
ISeoDataService.BuildAsync() — all the assembly happens here
↓
MetaTagsViewModel
↓
MetaTags.cshtml — renders every tag: title, OG, Twitter, robots, canonical, JSON-LD
Worth being precise about this shape because it's easy to guess wrong: the Open Graph and Twitter Card markup does not live in Layout.cshtml — the layout only invokes the view component; every tag is rendered by Views/Shared/Components/MetaTags/MetaTags.cshtml. If you're hunting for a specific meta tag, that partial is always where to look, and SeoDataService is always where its value was decided.
Building the view model: SeoDataService
ISeoDataService.BuildAsync(IPublishedContent currentPage) is the entire public surface — one method, returning a MetaTagsViewModel. Its shape:
- Seed
MetaTitlefromcurrentPage.NameandSiteNamefromGetSiteSettings()(tenant-scoped, per the multi-tenancy primer). - Branch on
currentPage is ICompositionSeo contentModel— the same "check the interface, not a doc-type list" idiom the content-modelling primer recommends. If the page composes it:MetaTitlefalls back to the page name when the editor left it blank,OpenGraphImageUrlresolves fromOgImage,RobotsandCanonicalUrlget filled in, pagination is applied, and the schema builders run. - If the page doesn't compose
ICompositionSeo—DigitalSignagePageis the one example today — the whole branch is skipped. OnlyMetaTitlegets set. No description, no OG image, no robots, no canonical, no schema. That's deliberate for a kiosk page with no SEO surface, but it's also the thing to remember if you add a new page type and its meta tags mysteriously don't render: check whether it composesICompositionSeoat all. - One unconditional special case runs regardless of branch: the
Documentationnode. Because a single content node serves every/docs/...URL, its node-derived title/canonical would otherwise collapse every rendered article onto the same/docs/URL — soSeoDataServiceoverwritesCanonicalUrl(built from the live request path),MetaTitle, and a 200-character-truncatedMetaDescriptionfrom whichever doc was actually resolved.
The "Page Title | Site Name" title format isn't built here, though — MetaTitle and SiteName stay separate fields on the view model, and the concatenation happens in MetaTags.cshtml's <title> tag. If you ever need to change the title format sitewide, that's the one line to edit.
Schema builders
ViewModelBuilders/Schema/ has three builders — ArticleSchemaBuilder, OrganizationSchemaBuilder, BreadcrumbSchemaBuilder — and deliberately no shared interface between them. Each builds a different Schema.NET (13.0.0, centrally pinned) type from different inputs, so a common ISchemaBuilder<T> abstraction wouldn't buy much:
ArticleSchemaBuilder— buildsSchema.NET.ArticleforArticlecontent only (returnsnullotherwise): headline, publish/update dates, author, image, description, and aPublisherbuilt by delegating straight toOrganizationSchemaBuilder.OrganizationSchemaBuilder— buildsSchema.NET.Organization, and never returnsnull. This is the one with the tenant-aware fallback: a singlehasCustomSettingsflag gates name/URL/logo together — either the tenant'sSocialSettingsare complete enough to use, or all three fields fall back to hardcoded Umbraco defaults. The tenant-aware schema fallback tutorial walks through why that's all-or-nothing rather than falling back field-by-field; this primer won't re-derive it.BreadcrumbSchemaBuilder— buildsSchema.NET.BreadcrumbListby walkingcontent.Ancestors().Reverse()down to the current page. Returnsnullfor the home page itself (no ancestors, no breadcrumb).
An editor-authored escape hatch sits alongside all three: ICompositionSeo.CustomSchema is a raw string that gets added to the page's schema markup untouched — no Schema.NET processing, no validation, straight passthrough. A single article page can end up with several JSON-LD blocks at once (custom schema, Article-or-WebPage, and Breadcrumb, all independently opted in).
Every built object is serialised with Schema.NET's own .ToHtmlEscapedString() and collected onto the view model; MetaTags.cshtml loops that collection and emits one <script type="application/ld+json" asp-add-nonce="true"> per entry — the asp-add-nonce is what keeps JSON-LD compliant with the site's CSP (see NonceTagHelper in the backend primer).
OpenGraph, Twitter Cards, and the Sessionize deep-link override
MetaTags.cshtml emits, in order: og:title/twitter:title, description/og:description/twitter:description, a hardcoded og:type of "website" (there's no article branch, even for Article pages — worth knowing if you ever go looking for one and can't find it), og:site_name, og:locale, twitter:card (summary_large_image), a hardcoded twitter:site handle, then conditionally og:image/twitter:image (with explicit 1200×628 dimensions) and the canonical/prev/next links covered below.
Sessionize session sharing overrides several of those tags server-side for one specific case: a URL visited with ?session={sessionId} (see the Sessionize section of CLAUDE.md for the deep-linking feature this serves). The override lives inline in SeoDataService as a private method, ApplySessionOpenGraphOverridesAsync — if you've seen a SeoMetaDataViewModelDecorator mentioned elsewhere, that class doesn't exist; this method is the real implementation. It looks up the session via the same SessionizeApiClient the Sessionize feature already uses, and on a hit overwrites MetaTitle ("{session title} by {speakers}"), MetaDescription, and appends ?session={id} onto the canonical/og:url — so a link shared to LinkedIn, Bluesky, or Mastodon unfurls with that session's own preview instead of the generic program page's. A failed lookup is swallowed silently and the page's default tags stand.
Canonical URLs and pagination
Both are computed in SeoDataService, not the view. CanonicalUrl is built from the page's Umbraco URL made absolute against the current request's scheme and host — which is what makes canonical URLs automatically tenant-correct with no extra branching: whichever domain served the request is the domain the canonical URL points back at.
Pagination reads a ?page= query parameter and, when present, sets PrevUrl (omitting the query string entirely when linking back to page 1) and NextUrl, then — after computing both — overwrites CanonicalUrl itself to include the current ?page= value, so the canonical tag for page 3 of a listing points at page 3, not page 1. MetaTags.cshtml renders rel="canonical", rel="prev", and rel="next" link tags, each only when the corresponding value is non-empty.
Robots directives
ICompositionSeo.Robots is a free-ish string field, and MetaTags.cshtml matches it with exact string equality against a small known set of robots meta tag directives: empty in development renders noindex, nofollow as a blanket safety net; empty in production renders a permissive default (index, follow plus rich-snippet directives); "noindex, follow" and "noindex, nofollow" render as-is; "index, nofollow" gets the same rich-snippet directives appended as the default case. Anything else — a typo, an unexpected value — falls through every branch and emits no robots tag at all, silently. If you're adding a new robots option, it needs its own branch here or it'll be a no-op.
Sitemap generation
SitemapController (a plain RenderController behind a 60-second response cache) resolves context.PublishedRequest?.PublishedContent?.Root() as the sitemap's starting node — which means the sitemap is per-tenant by construction, not one combined document: a request to a given domain's /sitemap.xml only ever walks that domain's own subtree, the same way every other tenant-scoped lookup in this codebase works.
ContentDataService.GetSitemap walks the tree with two independent checks:
HideFromSitemap(fromICompositionPageConfiguration, the sibling ofHideFromSearch) skips the entire branch when set — children are never visited, so it's inherited by exclusion.- A node is only emitted as a
<url>entry when it has a template and composesICompositionPageConfiguration— so non-routable structural nodes (a Blog year/month folder, say) are traversed but never listed themselves, while their routable descendants (articles) still are.
The output is a bare-bones sitemaps.org protocol document — <loc> and <lastmod> only, no changefreq/priority.
Where to go next
- Content modelling primer —
ICompositionSeoand the other compositions this pipeline consumes. - How to keep SEO schema valid when a tenant hasn't configured their brand settings — the full walkthrough of
OrganizationSchemaBuilder's all-or-nothing fallback, including alternatives considered. - Multi-tenancy primer — why canonical URLs and sitemaps come out tenant-correct without any SEO-specific tenant logic.
- Backend primer — where
MetaTagsViewComponentsits among the other layout-slot ViewComponents, and the CSP nonce system JSON-LD scripts rely on.
That's the whole pipeline, start to finish — welcome aboard!