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 seoComposition and openGraphComposition on each page).

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 site = getSiteConfig();
// Fetch page content
const page = await client.getContentByPath(site.key, locale, slug.join('/'));
if (!page) return {};
return {
title: page.seo?.metaTitle || `${page.name} | ${site.name}`,
description: page.seo?.metaDescription || site.defaultMetaDescription,
keywords: page.seo?.metaKeywords,
// Canonical URL (self-referencing by default)
alternates: {
canonical: page.seo?.canonicalUrl || `https://${site.domain}/${locale}/${slug.join('/')}`,
languages: generateHreflangMap(page.cultures, site.domain),
},
// Robots directives
robots: {
index: !page.seo?.noIndex,
follow: !page.seo?.noFollow,
},
// Open Graph
openGraph: {
title: page.seo?.ogTitle || page.seo?.metaTitle || page.name,
description: page.seo?.ogDescription || page.seo?.metaDescription,
url: `https://${site.domain}/${locale}/${slug.join('/')}`,
siteName: site.name,
images: [
{
url: page.seo?.ogImage?.url || site.defaultOgImage.url,
width: 1200,
height: 630,
},
],
type: page.seo?.ogType || 'website',
},
// Twitter Card
twitter: {
card: page.seo?.twitterCardType || 'summary_large_image',
},
};
}

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

FieldBehavior if empty
metaTitleFallback to: `{Page Name}
metaDescriptionFallback to siteRoot.defaultMetaDescription
noIndexDefault: false (Indexed)
noFollowDefault: false (Followed)
metaKeywordsFor reference only (not used by modern search engines)
canonicalUrlFallback to current relative URL mapped to absolute domain
FieldBehavior if empty
ogTitleFallback to metaTitle → Page Name
ogDescriptionFallback to metaDescription
ogImageFallback to siteRoot.defaultOgImage
ogTypeDefault: website (can be article for news)
twitterCardTypeDefault: summary_large_image

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}) returns a simplified node tree:

[
{
"url": "/pt/alojamento",
"lastModified": "2026-03-04T10:00:00Z",
"priority": 0.8,
"variants": [
{ "locale": "pt", "path": "/pt/alojamento" },
{ "locale": "en", "path": "/en/accommodation" }
]
}
]
apps/web/src/app/sitemap.ts
import { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const site = getSiteConfig();
const nodes = await fetchSitemapData(site.key); // from Umbraco custom API
return nodes.map((node) => ({
url: `https://${site.domain}${node.url}`,
lastModified: new Date(node.lastModified),
changeFrequency: 'weekly',
priority: node.priority || 0.5,
alternates: {
languages: node.variants.reduce((acc, v) => ({
...acc,
[v.locale]: `https://${site.domain}${v.path}`
}), {}),
},
}));
}

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

Injected on the homepage of every hotel site.

{
"@context": "https://schema.org",
"@type": "Hotel",
"name": "Savoy Palace",
"description": "Luxury 5-star hotel in Madeira",
"image": "https://savoysignature.com/media/.../palace.jpg",
"url": "https://savoysignature.com/savoypalacehotel",
"telephone": "+351 291 213 000",
"address": {
"@type": "PostalAddress",
"streetAddress": "Avenida do Infante 25",
"addressLocality": "Funchal",
"postalCode": "9004-542",
"addressCountry": "PT"
},
"starRating": {
"@type": "Rating",
"ratingValue": "5"
}
}
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://savoysignature.com/savoypalacehotel/pt"
},
{
"@type": "ListItem",
"position": 2,
"name": "Alojamento",
"item": "https://savoysignature.com/savoypalacehotel/pt/alojamento"
}
]
}
Page TypeSchema TypeKey Properties Mapped
roomDetailPageHotelRoomName, Image, Max Guests, Bed Type, Amenities
diningDetailPageRestaurantName, Image, Cuisine, Opening Hours, Menu URL
newsDetailPageArticleHeadline, Image, Date Published, Author
faqPageFAQPageList of Question and Answer

6.4 Schema Override via CMS (schemaComposition)

Section titled “6.4 Schema Override via CMS (schemaComposition)”

While the platform generates schema automatically based on page type, editors have the ability to override or extend schema fields manually per page.

A dedicated schemaComposition provides the following fields:

  • schemaOverride (Boolean): If true, suppresses automatic generation.
  • customJsonLd (Code Editor / JSON): Allows the editor to paste custom JSON-LD.
  • Key properties like “Hotel Telephone”, “Max Guests”, or “Address” mapped to automatic schemas must also be editable fields in Umbraco to ensure structured data is always accurate without requiring code deployments.
import Script from 'next/script';
export function JsonLd({ schema }: { schema: Record<string, any> }) {
return (
<Script
id={`json-ld-${schema['@type']}`}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}

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 dynamically generated per site, including hreflang links, and excluding noIndex pages.
  • JSON-LD Structured Data injected correctly on Homepage, Room details, Restaurant details, and FAQs.
  • Breadcrumb JSON-LD corresponds exactly to the visual breadcrumbs component.
  • Changing a page URL in Umbraco automatically generates a 301 redirect.
  • Deleted pages return a 404 status code (not a soft 404/redirect to home).

Next document: 12_Forms_and_Data_Collection.md