10 — Multi-Language and i18n
PRD Document · Savoy Signature Hotels — Multi-Site Headless Platform
Version: 1.0 · Date: 2026-03-04
Related docs:03_MultiSite_and_Domains.md,04_Frontend_Architecture.md,06_Content_Modeling_Umbraco.md,11_SEO_and_Metadata.md
1. Purpose
Section titled “1. Purpose”This document defines the multi-language and internationalization (i18n) strategy for the platform. It covers language configuration, URL structure, content variant management, media language fallback, UI label translation, and SEO considerations for multi-language sites.
2. Language Configuration
Section titled “2. Language Configuration”2.1 Supported Languages
Section titled “2.1 Supported Languages”| Language | Code | Mandatory | Default | Primary Use |
|---|---|---|---|---|
| Portuguese | pt | ✅ | ✅ | Primary language for all content |
| English | en | ❌ | ❌ | Secondary language (optional per page) |
2.2 Key Principles
Section titled “2.2 Key Principles”| Principle | Description |
|---|---|
| Portuguese is the default | Every content node is created in pt by default. The pt variant should be published for all standard site pages |
| English is optional | EN variant published only when translation is available. No auto-translation |
| EN-only pages allowed | It is possible to publish a page only in EN (e.g., a landing page targeted at international media/advertising). These pages will return 404 when accessed via /pt/... |
| No cross-language fallback | If a user requests a locale that isn’t published for that page, return 404. PT content is never shown to EN users (and vice versa) |
| URL-based locale | Language is determined by URL segment (/pt/..., /en/...) |
| Same site structure | Both languages share the same content tree — no separate content trees per language |
3. URL Structure
Section titled “3. URL Structure”3.1 URL Pattern
Section titled “3.1 URL Pattern”https://{domain}/{locale}/{path}
Examples: https://savoysignature.com/pt/sobre-nos https://savoysignature.com/en/about-us https://savoysignature.com/pt/alojamento/quarto-deluxe-oceano https://savoysignature.com/en/accommodation/deluxe-ocean-room3.2 Locale Detection & Redirects
Section titled “3.2 Locale Detection & Redirects”3.3 Locale Configuration in proxy.ts
Section titled “3.3 Locale Configuration in proxy.ts”import { NextRequest, NextResponse } from 'next/server';
const SUPPORTED_LOCALES = ['pt', 'en'] as const;const DEFAULT_LOCALE = 'pt';
export function proxy(request: NextRequest) { const site = resolveSiteFromRequest(request); const pathname = request.nextUrl.pathname;
// Remove site path prefix if using path-based routing const cleanPath = pathname.replace(site.pathPrefix, '');
// Extract locale from first segment const segments = cleanPath.split('/').filter(Boolean); const locale = segments[0];
// Redirect root to default locale if (!locale || !SUPPORTED_LOCALES.includes(locale as any)) { return NextResponse.redirect( new URL(`${site.pathPrefix}/${DEFAULT_LOCALE}${cleanPath}`, request.url) ); }
const response = NextResponse.next(); response.headers.set('x-site-key', site.key); response.headers.set('x-site-theme', site.theme); response.headers.set('x-locale', locale); return response;}4. Content Variants in Umbraco
Section titled “4. Content Variants in Umbraco”4.1 How It Works
Section titled “4.1 How It Works”Umbraco’s built-in Language Variants feature is used:
- Each content node stores separate values for
ptanden - Properties can be set as “vary by culture” or “invariant” (shared across languages)
- Editors switch between languages in the backoffice using the language selector
4.2 Property Variance Rules
Section titled “4.2 Property Variance Rules”| Property Type | Varies by Culture? | Rationale |
|---|---|---|
| Text content (titles, descriptions, body) | ✅ Yes | Different per language |
| SEO fields (meta title, meta description) | ✅ Yes | Unique per language |
| URL slug | ✅ Yes | Different slugs per language (e.g., alojamento vs accommodation) |
| Media (images) | ✅ Yes | PT serves as default; EN override if exists (see section 6) |
| Numeric values (size, max guests) | ❌ No | Same value in all languages |
| Toggles (bookingCta, noIndex) | ❌ No | Same behavior in all languages |
| Publish per language | ✅ Yes | Each language variant can be published independently. PT can be published without EN, and EN can be published without PT |
| Content Pickers (related content) | ❌ No | Same structure in all languages |
| Configuration (theme, siteKey) | ❌ No | Site-wide settings |
4.3 Content Delivery API Language Handling
Section titled “4.3 Content Delivery API Language Handling”// Requesting Portuguese contentconst ptResponse = await fetch('/umbraco/delivery/api/v2/content/item/alojamento/quarto-deluxe', { headers: { 'Accept-Language': 'pt', 'Start-Item': 'savoy-palace' },});
// Requesting English contentconst enResponse = await fetch('/umbraco/delivery/api/v2/content/item/accommodation/deluxe-room', { headers: { 'Accept-Language': 'en', 'Start-Item': 'savoy-palace' },});
// Response includes available cultures:// {// "cultures": {// "pt": { "path": "/pt/alojamento/quarto-deluxe" },// "en": { "path": "/en/accommodation/deluxe-room" }// }// }5. URL Slug Translation
Section titled “5. URL Slug Translation”5.1 Rules
Section titled “5.1 Rules”| Rule | Description |
|---|---|
| Slugs are manually translated | Editors set the slug for each language in Umbraco |
| No auto-translation | The CMS does NOT auto-generate EN slugs from PT content |
| Slug format | Lowercase, hyphens, no special characters (kebab-case) |
| Unique per level | Slugs must be unique among siblings at each tree level |
5.2 Examples
Section titled “5.2 Examples”| PT Slug | EN Slug | Page |
|---|---|---|
/pt/alojamento | /en/accommodation | Rooms list |
/pt/alojamento/suite-presidencial | /en/accommodation/presidential-suite | Room detail |
/pt/restaurantes/galaxia-skyfood | /en/restaurants/galaxia-skyfood | Restaurant detail |
/pt/sobre-nos | /en/about-us | About page |
/pt/contactos | /en/contacts | Contact page |
Note: Some slugs (like proper nouns — “Galáxia Skyfood”) may remain the same in both languages.
6. Media Language Fallback
Section titled “6. Media Language Fallback”As defined in 06_Content_Modeling_Umbraco.md, images follow a fallback strategy:
6.1 Behavior
Section titled “6.1 Behavior”6.2 Implementation
Section titled “6.2 Implementation”export function resolveMediaForLocale( mediaProperty: { pt?: UmbracoMedia; en?: UmbracoMedia } | UmbracoMedia, locale: string): UmbracoMedia | null { // If media is invariant (not varying by culture) if ('url' in mediaProperty) return mediaProperty;
// If locale variant exists, use it if (locale === 'en' && mediaProperty.en) return mediaProperty.en;
// Default: PT return mediaProperty.pt || null;}6.3 When EN Override is Needed
Section titled “6.3 When EN Override is Needed”| Image contains… | Action |
|---|---|
| Pure photography (landscapes, rooms, pools) | ❌ No EN variant needed |
| Text overlays or labels | ✅ Upload EN variant |
| Infographics with textual data | ✅ Upload EN variant |
| Certificates or awards with language | ✅ Upload EN variant |
| Signage visible in the photo | ⚠️ Optional (case-by-case) |
7. UI Labels / Dictionary
Section titled “7. UI Labels / Dictionary”7.1 Umbraco Dictionary
Section titled “7.1 Umbraco Dictionary”Fixed UI labels (not managed in the content tree) are stored in the Umbraco Dictionary:
Dictionary├── Global│ ├── ReadMore → { pt: "Ler Mais", en: "Read More" }│ ├── BookNow → { pt: "Reservar", en: "Book Now" }│ ├── ViewAll → { pt: "Ver Todos", en: "View All" }│ ├── BackToTop → { pt: "Voltar ao Topo", en: "Back to Top" }│ ├── Close → { pt: "Fechar", en: "Close" }│ └── Loading → { pt: "A Carregar...", en: "Loading..." }├── Navigation│ ├── Menu → { pt: "Menu", en: "Menu" }│ ├── Search → { pt: "Pesquisar", en: "Search" }│ ├── Language → { pt: "Idioma", en: "Language" }│ └── SkipToContent → { pt: "Saltar para conteúdo", en: "Skip to content" }├── Booking│ ├── CheckIn → { pt: "Check-in", en: "Check-in" }│ ├── CheckOut → { pt: "Check-out", en: "Check-out" }│ ├── Guests → { pt: "Hóspedes", en: "Guests" }│ ├── Adults → { pt: "Adultos", en: "Adults" }│ ├── Children → { pt: "Crianças", en: "Children" }│ ├── PromoCode → { pt: "Código Promocional", en: "Promo Code" }│ └── SearchAvailability → { pt: "Pesquisar Disponibilidade", en: "Search Availability" }├── Forms│ ├── Submit → { pt: "Enviar", en: "Submit" }│ ├── RequiredField → { pt: "Campo obrigatório", en: "Required field" }│ ├── InvalidEmail → { pt: "Email inválido", en: "Invalid email" }│ └── SuccessMessage → { pt: "Mensagem enviada com sucesso", en: "Message sent successfully" }├── Rooms│ ├── From → { pt: "Desde", en: "From" }│ ├── PerNight → { pt: "/noite", en: "/night" }│ ├── SqMeters → { pt: "m²", en: "sqm" }│ ├── MaxGuests → { pt: "Hóspedes máx.", en: "Max guests" }│ └── Amenities → { pt: "Comodidades", en: "Amenities" }└── Errors ├── PageNotFound → { pt: "Página não encontrada", en: "Page not found" } ├── ServerError → { pt: "Erro no servidor", en: "Server error" } └── TryAgain → { pt: "Tentar novamente", en: "Try again" }7.2 Fetching Dictionary Items
Section titled “7.2 Fetching Dictionary Items”export async function getDictionary(locale: string): Promise<Record<string, string>> { const response = await fetch( `${UMBRACO_API}/api/dictionary?locale=${locale}`, { cache: 'no-store' } ); return response.json();}
// Usage in componentconst dict = await getDictionary(locale);// dict.ReadMore → "Read More" or "Ler Mais"8. SEO for Multi-Language
Section titled “8. SEO for Multi-Language”8.1 HTML Lang Attribute
Section titled “8.1 HTML Lang Attribute”<html lang="pt"> <!-- or lang="en" -->8.2 Hreflang Tags
Section titled “8.2 Hreflang Tags”Every page renders <link rel="alternate" hreflang="..."> for all available language variants:
export function generateAlternateLinks( cultures: Record<string, { path: string }>): React.ReactNode[] { return Object.entries(cultures).map(([locale, culture]) => ( <link key={locale} rel="alternate" hrefLang={locale} href={`https://${domain}${culture.path}`} /> ));}
// Output:// <link rel="alternate" hreflang="pt" href="https://savoysignature.com/pt/sobre-nos" />// <link rel="alternate" hreflang="en" href="https://savoysignature.com/en/about-us" />// <link rel="alternate" hreflang="x-default" href="https://savoysignature.com/pt/sobre-nos" />8.3 Sitemap per Language
Section titled “8.3 Sitemap per Language”Each site generates a sitemap with language annotations:
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml"> <url> <loc>https://savoysignature.com/pt/sobre-nos</loc> <xhtml:link rel="alternate" hreflang="pt" href="https://savoysignature.com/pt/sobre-nos"/> <xhtml:link rel="alternate" hreflang="en" href="https://savoysignature.com/en/about-us"/> </url> <url> <loc>https://savoysignature.com/en/about-us</loc> <xhtml:link rel="alternate" hreflang="pt" href="https://savoysignature.com/pt/sobre-nos"/> <xhtml:link rel="alternate" hreflang="en" href="https://savoysignature.com/en/about-us"/> </url></urlset>8.4 Canonical URLs
Section titled “8.4 Canonical URLs”| Scenario | Canonical |
|---|---|
| PT page | Self-referencing canonical to PT URL |
| EN page | Self-referencing canonical to EN URL |
| Duplicate content | Canonical to the preferred version |
9. Language Switcher Component
Section titled “9. Language Switcher Component”9.1 Behavior
Section titled “9.1 Behavior”9.2 Implementation
Section titled “9.2 Implementation”'use client';
interface LanguageSwitcherProps { currentLocale: string; availableLocales: { locale: string; path: string }[];}
export function LanguageSwitcher({ currentLocale, availableLocales }: LanguageSwitcherProps) { return ( <nav aria-label="Language selector"> {availableLocales.map(({ locale, path }) => ( <a key={locale} href={path} lang={locale} aria-current={locale === currentLocale ? 'true' : undefined} className={locale === currentLocale ? 'active' : ''} > {locale.toUpperCase()} </a> ))} </nav> );}10. Date and Number Formatting
Section titled “10. Date and Number Formatting”| Type | PT Format | EN Format | Implementation |
|---|---|---|---|
| Date | 04 de Março de 2026 | March 4, 2026 | Intl.DateTimeFormat(locale) |
| Date (short) | 04/03/2026 | 03/04/2026 | Intl.DateTimeFormat(locale, { dateStyle: 'short' }) |
| Currency | € 250,00 | €250.00 | Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }) |
| Number | 1.234,56 | 1,234.56 | Intl.NumberFormat(locale) |
11. Acceptance Criteria
Section titled “11. Acceptance Criteria”-
/redirects to/pt/(302) -
/en/...returns 404 if EN variant is not published - All pages have correct
<html lang="...">attribute - Hreflang tags are present on all pages with the correct alternate URLs
- Language switcher navigates to the correct localized URL (or EN homepage if variant doesn’t exist)
- Sitemaps include hreflang annotations for all available variants
- URL slugs are separately managed in PT and EN by editors
- Dictionary items are correctly fetched and rendered per locale
- Media fallback works: PT image served for EN when no EN variant exists
- Date and number formatting respects the current locale
- Umbraco backoffice allows editors to easily switch between language variants
Next document: 11_SEO_and_Metadata.md