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
1. Purpose
Section titled “1. Purpose”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).
2. Metadata Architecture
Section titled “2. Metadata Architecture”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).
2.1 Metadata Resolution Hierarchy
Section titled “2.1 Metadata Resolution Hierarchy”Next.js will construct the final <head> metadata in the following order of precedence:
- Page-specific override (e.g.,
metaTitleonroomDetailPage) - Dynamic generation (e.g., “Room Name — Hotel Name”)
- Site default (e.g.,
defaultMetaTitleonsiteRoot) - Hardcoded fallback (application level)
2.2 Next.js Metadata API Implementation
Section titled “2.2 Next.js Metadata API Implementation”The platform uses the Next.js App Router Metadata API.
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/3. SEO Compositions in Umbraco
Section titled “3. SEO Compositions in Umbraco”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)”| Field | Alias | Behavior if empty |
|---|---|---|
| Meta Title | metaTitle | Fallback: {Page Name} | {Site Name} → {Site Name} |
| Meta Description | metaDescription | Fallback: siteRoot.metaDescription |
| No Index | noIndex | Default: false (indexed) |
| No Follow | noFollow | Default: false (followed) |
| Canonical URL | canonicalUrl | Fallback: self-referential via buildSeoUrl() |
| OG Title | ogTitle | Fallback: same as title |
| OG Description | ogDescription | Fallback: same as description |
| OG Image | ogImage | Fallback: siteRoot.ogImage |
| JSON-LD Generated | jsonLdGenerated | Auto-populated on save (Textarea, read in backoffice) |
| JSON-LD Custom | jsonLdCustom | Editor override — replaces auto-generated by @type match |
Note:
openGraphCompositionwas deleted and merged intoseoComposition.metaKeywordswas removed (not used by modern search engines).ogTypeandtwitterCardTypeare hardcoded aswebsiteandsummary_large_imagerespectively — no CMS fields needed for v1.
4. Robots.txt
Section titled “4. Robots.txt”The robots.txt file is generated dynamically per site, allowing granular control for each hotel domain.
4.1 Implementation
Section titled “4.1 Implementation”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`, };}5. Sitemaps (XML)
Section titled “5. Sitemaps (XML)”Sitemaps are generated dynamically per site, indexing all published pt and en pages.
5.1 Generation Strategy
Section titled “5.1 Generation Strategy”- Dynamic: Generated via Next.js
sitemap.tscalling 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: trueare excluded entirely.
5.2 Sitemap Endpoint Response
Section titled “5.2 Sitemap Endpoint Response”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.
5.3 Next.js Implementation
Section titled “5.3 Next.js Implementation”Architecture: API Route Handlers + next.config.ts rewrites (not app/sitemap.ts metadata route — generateSitemaps() is broken with Turbopack in dev mode).
| URL | Handler |
|---|---|
/sitemap.xml | app/api/sitemap/route.ts → sitemap index listing all hotel sitemaps |
/sitemap/savoy-palace.xml | app/api/sitemap/[id]/route.ts → per-hotel sitemap |
/sitemap/royal-savoy.xml | Same 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.
6. Structured Data (Schema.org)
Section titled “6. Structured Data (Schema.org)”JSON-LD structured data is injected into specific page types to generate rich snippets in Google Search results.
6.1 Hotel Schema (siteRoot)
Section titled “6.1 Hotel Schema (siteRoot)”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": "€€€€"}6.2 Breadcrumb Schema (All inner pages)
Section titled “6.2 Breadcrumb Schema (All inner pages)”{ "@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/" } ]}6.3 Other Supported Schemas
Section titled “6.3 Other Supported Schemas”| Page Type | Schema Type | Status | Key Properties Mapped |
|---|---|---|---|
diningDetailPage | Restaurant | Implemented | Name, Cuisine, parentOrganization (Hotel) |
| Pages with M13 FAQs | FAQPage | Implemented | Auto-detected from faqs module blocks |
roomDetailPage | HotelRoom | Deferred (v2) | Name, Image, Max Guests, Bed Type, Amenities |
newsDetailPage | Article | Deferred (v2) | Headline, Image, Date Published, Author |
eventDetailPage | Event | Deferred (v2) | Name, Date, Location |
6.4 Schema Override via CMS
Section titled “6.4 Schema Override via CMS”The seoComposition includes a jsonLdCustom (Textarea) field for editor overrides:
- If the custom JSON-LD has an
@typematching an auto-generated schema, it replaces it - If the
@typeis 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 ofseoCompositionunder theseo/jsonLdsub-group. Hotel data properties are on a separatehotelDataCompositionapplied tositeRoot.
6.5 React Implementation
Section titled “6.5 React Implementation”The JsonLd component lives in packages/ui/src/JsonLd/JsonLd.tsx. It uses native <script> (not next/script) for Server Component compatibility. Content is JSON.stringify of server-generated schema objects — safe, not user input.
6.6 Schema Orchestration
Section titled “6.6 Schema Orchestration”buildPageJsonLd() in lib/seo/build-jsonld.ts selects schemas by contentType:
| Content Type | Schemas Generated |
|---|---|
siteRoot | Hotel + (no BreadcrumbList for root) |
diningDetailPage | Restaurant + BreadcrumbList |
Page with faqs modules | FAQPage + BreadcrumbList |
| Any other inner page | BreadcrumbList only |
7. URL Redirects Management
Section titled “7. URL Redirects Management”Editors can manage 301 (Permanent) and 302 (Temporary) redirects directly in Umbraco to handle legacy URLs or marketing campaigns.
7.1 Architecture
Section titled “7.1 Architecture”- Umbraco Redirect Tracker: Native tool tracks when a node is renamed/moved and auto-creates a 301.
- Custom Redirects Dashboard: Custom table in Umbraco for manual wildcard/regex redirects.
- Frontend Resolution:
proxy.tsqueries the Umbraco redirects API before rendering a 404.
7.2 Performance Consideration
Section titled “7.2 Performance Consideration”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.
8. Acceptance Criteria
Section titled “8. Acceptance Criteria”-
<title>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.txtdynamically servesDisallow: /on STAGE/DEV environments. -
/sitemap.xmlreturns sitemap index with per-hotel sub-sitemaps (/sitemap/savoy-palace.xml, etc.) - Sub-sitemaps include hreflang alternates and exclude
noIndexpages. - 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.
-
jsonLdCustomoverride replaces/appends by@type. -
jsonLdGeneratedpreview 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
resolveContent→notFound())
Next document: 12_Forms_and_Data_Collection.md