Skip to content

11 — SEO and Metadata

PRD Document · Savoy Signature Hotels — Multi-Site Headless Platform
Version: 1.0 · Date: 2026-03-04
Related docs: 06_Content_Modeling_Umbraco.md, 10_MultiLanguage_and_i18n.md


This document defines the SEO strategy and metadata implementation for the headless platform. It covers Next.js metadata generation, Open Graph (social sharing), dynamic sitemaps, robots.txt, canonical URLs, and structured data (Schema.org).


Metadata is composed of base site configuration (defined in the siteRoot node) and page-specific overrides (defined in the unified seoComposition on each page — Open Graph fields are included in the SEO tab, not a separate composition).

Next.js will construct the final <head> metadata in the following order of precedence:

  1. Page-specific override (e.g., metaTitle on roomDetailPage)
  2. Dynamic generation (e.g., “Room Name — Hotel Name”)
  3. Site default (e.g., defaultMetaTitle on siteRoot)
  4. Hardcoded fallback (application level)

The platform uses the Next.js App Router Metadata API.

apps/web/src/app/[locale]/[...slug]/page.tsx
import { Metadata } from 'next';
export async function generateMetadata({ params }): Promise<Metadata> {
const { locale, slug } = await params;
const siteKey = headersList.get('x-site-key') || getDefaultSiteKey();
const siteConfig = getSiteByKey(siteKey);
const page = await resolveContent(slug, locale, siteKey);
const siteRoot = await cms.getSiteRoot(siteKey, { locale });
return buildMetadata(page, siteRoot, siteConfig, locale);
}
// buildMetadata() in lib/seo/build-metadata.ts applies the 3-level fallback:
// 1. Page override (metaTitle, ogTitle, etc.)
// 2. Dynamic generation (page.name | site.name)
// 3. Site default (siteRoot metaDescription, ogImage)
//
// buildAlternates() in lib/seo/build-hreflang.ts builds hreflang from page.cultures
// buildSeoUrl() in lib/seo/build-seo-url.ts uses siteConfig.domain (NOT NEXT_PUBLIC_SITE_URL)
// Canonical URL examples:
// Savoy Signature: https://www.savoysignature.com/pt/sobre-nos/
// Savoy Palace: https://www.savoysignature.com/pt/savoypalacehotel/alojamento/
// Hotel Next: https://hotelnext.pt/pt/sobre-nos/

As defined in 06_Content_Modeling_Umbraco.md, the following compositions control SEO per page.

3.1 seoComposition (unified — includes OG and JSON-LD)

Section titled “3.1 seoComposition (unified — includes OG and JSON-LD)”
FieldAliasBehavior if empty
Meta TitlemetaTitleFallback: {Page Name} | {Site Name}{Site Name}
Meta DescriptionmetaDescriptionFallback: siteRoot.metaDescription
No IndexnoIndexDefault: false (indexed)
No FollownoFollowDefault: false (followed)
Canonical URLcanonicalUrlFallback: self-referential via buildSeoUrl()
OG TitleogTitleFallback: same as title
OG DescriptionogDescriptionFallback: same as description
OG ImageogImageFallback: siteRoot.ogImage
JSON-LD GeneratedjsonLdGeneratedAuto-populated on save (Textarea, read in backoffice)
JSON-LD CustomjsonLdCustomEditor override — replaces auto-generated by @type match

Note: openGraphComposition was deleted and merged into seoComposition. metaKeywords was removed (not used by modern search engines). ogType and twitterCardType are hardcoded as website and summary_large_image respectively — no CMS fields needed for v1.


The robots.txt file is generated dynamically per site, allowing granular control for each hotel domain.

apps/web/src/app/robots.ts
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const site = getSiteConfig(); // Resolves based on host/headers
// Base rules
const rules = [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/_next/', '/preview/'],
}
];
// If STAGE/DEV environment, block all crawling
if (process.env.APP_ENV !== 'production') {
return {
rules: [{ userAgent: '*', disallow: '/' }],
};
}
return {
rules,
sitemap: `https://${site.domain}/sitemap.xml`,
};
}

Sitemaps are generated dynamically per site, indexing all published pt and en pages.

  • Dynamic: Generated via Next.js sitemap.ts calling a custom Umbraco endpoint.
  • Multi-language: Includes <xhtml:link rel="alternate"> tags for hreflang mapping within the sitemap.
  • Caching: Sitemap is cached at the Cloudflare edge to prevent heavy DB hits.
  • Exclusions: Pages marked noIndex: true are excluded entirely.

The custom Umbraco endpoint (/api/sitemap/{siteKey}, implemented in SitemapEndpoints.cs) returns all published pages under a site’s content tree. Paths already include the locale and hotel slug as used in the Umbraco content structure.

[
{
"contentType": "accommodationListPage",
"lastModified": "2026-03-04T10:00:00Z",
"variants": [
{ "locale": "pt", "path": "/pt/savoypalacehotel/alojamento" },
{ "locale": "en", "path": "/en/savoypalacehotel/accommodation" }
]
}
]

The frontend prepends https://{siteConfig.domain} directly to the variant path — no additional slug insertion needed.

Architecture: API Route Handlers + next.config.ts rewrites (not app/sitemap.ts metadata route — generateSitemaps() is broken with Turbopack in dev mode).

URLHandler
/sitemap.xmlapp/api/sitemap/route.ts → sitemap index listing all hotel sitemaps
/sitemap/savoy-palace.xmlapp/api/sitemap/[id]/route.ts → per-hotel sitemap
/sitemap/royal-savoy.xmlSame handler, different id

Rewrites (in next.config.ts):

{ source: '/sitemap.xml', destination: '/api/sitemap' },
{ source: '/sitemap/:id.xml', destination: '/api/sitemap/:id' },

Multi-domain: www.savoysignature.com/sitemap.xml lists 7 shared-domain hotels + Hotel Next. Each sub-sitemap uses siteConfig.domain for absolute URLs, so Hotel Next URLs correctly use hotelnext.pt.


JSON-LD structured data is injected into specific page types to generate rich snippets in Google Search results.

Injected on the homepage (siteRoot) of every hotel site. Data comes from the hotelDataComposition on the siteRoot node (14 Textstring fields, all invariant).

{
"@context": "https://schema.org",
"@type": "Hotel",
"name": "Savoy Palace",
"url": "https://www.savoysignature.com/pt/savoypalacehotel",
"telephone": "+351 291 213 000",
"email": "palace@savoysignature.com",
"address": {
"@type": "PostalAddress",
"streetAddress": "Avenida do Infante, Nr. 25",
"addressLocality": "Funchal",
"addressRegion": "Madeira",
"postalCode": "9004-542",
"addressCountry": "PT"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": "32.643472",
"longitude": "-16.920833"
},
"starRating": { "@type": "Rating", "ratingValue": "5" },
"checkinTime": "14:00",
"checkoutTime": "12:00",
"priceRange": "€€€€"
}
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://www.savoysignature.com/pt/savoypalacehotel/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Alojamento",
"item": "https://www.savoysignature.com/pt/savoypalacehotel/alojamento/"
}
]
}
Page TypeSchema TypeStatusKey Properties Mapped
diningDetailPageRestaurantImplementedName, Cuisine, parentOrganization (Hotel)
Pages with M13 FAQsFAQPageImplementedAuto-detected from faqs module blocks
roomDetailPageHotelRoomDeferred (v2)Name, Image, Max Guests, Bed Type, Amenities
newsDetailPageArticleDeferred (v2)Headline, Image, Date Published, Author
eventDetailPageEventDeferred (v2)Name, Date, Location

The seoComposition includes a jsonLdCustom (Textarea) field for editor overrides:

  • If the custom JSON-LD has an @type matching an auto-generated schema, it replaces it
  • If the @type is different, it is appended to the page’s schemas
  • Malformed JSON is silently skipped

The jsonLdGenerated (Textarea) field shows a read-only preview of the auto-generated JSON-LD, updated automatically on content save via JsonLdGeneratorComponent (ContentSavingNotification handler).

Note: There is no separate schemaComposition — JSON-LD fields are part of seoComposition under the seo/jsonLd sub-group. Hotel data properties are on a separate hotelDataComposition applied to siteRoot.

The JsonLd component lives in packages/ui/src/JsonLd/JsonLd.tsx. It uses native &lt;script&gt; (not next/script) for Server Component compatibility. Content is JSON.stringify of server-generated schema objects — safe, not user input.

buildPageJsonLd() in lib/seo/build-jsonld.ts selects schemas by contentType:

Content TypeSchemas Generated
siteRootHotel + (no BreadcrumbList for root)
diningDetailPageRestaurant + BreadcrumbList
Page with faqs modulesFAQPage + BreadcrumbList
Any other inner pageBreadcrumbList only

Editors can manage 301 (Permanent) and 302 (Temporary) redirects directly in Umbraco to handle legacy URLs or marketing campaigns.

  1. Umbraco Redirect Tracker: Native tool tracks when a node is renamed/moved and auto-creates a 301.
  2. Custom Redirects Dashboard: Custom table in Umbraco for manual wildcard/regex redirects.
  3. Frontend Resolution: proxy.ts queries the Umbraco redirects API before rendering a 404.

To avoid blocking requests, proxy.ts only checks for redirects if the local routing determines the path doesn’t match an active Next.js route or static file.


  • &lt;title&gt; and <meta name="description"> correctly populated on all pages, adhering to fallback hierarchy.
  • Open Graph (og:*) and Twitter Card (twitter:*) tags present and valid.
  • robots.txt dynamically serves Disallow: / on STAGE/DEV environments.
  • /sitemap.xml returns sitemap index with per-hotel sub-sitemaps (/sitemap/savoy-palace.xml, etc.)
  • Sub-sitemaps include hreflang alternates and exclude noIndex pages.
  • JSON-LD Hotel schema on siteRoot pages (from hotelDataComposition).
  • JSON-LD Restaurant schema on diningDetailPage.
  • JSON-LD FAQPage schema auto-detected from M13 FAQ module blocks.
  • JSON-LD BreadcrumbList on all inner pages.
  • jsonLdCustom override replaces/appends by @type.
  • jsonLdGenerated preview auto-populated in Umbraco backoffice on save.
  • Hotel data editable per site via hotelDataComposition (all 8 sites populated).
  • Canonical URLs self-referential with hreflang PT + EN + x-default.
  • Changing a page URL in Umbraco automatically generates a 301 redirect. (Redirect management — separate task)
  • Deleted pages return a 404 status code. (Already handled by resolveContentnotFound())

Next document: 12_Forms_and_Data_Collection.md