Skip to content

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


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.


LanguageCodeMandatoryDefaultPrimary Use
PortugueseptPrimary language for all content
EnglishenSecondary language (optional per page)
PrincipleDescription
Portuguese is the defaultEvery content node is created in pt by default. The pt variant should be published for all standard site pages
English is optionalEN variant published only when translation is available. No auto-translation
EN-only pages allowedIt 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 fallbackIf 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 localeLanguage is determined by URL segment (/pt/..., /en/...)
Same site structureBoth languages share the same content tree — no separate content trees per language

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-room

No

/pt/...

/en/...

Yes

No

Yes

No

Request: /

proxy.ts

Has locale segment?

302 → /{defaultLocale}

PT variant published?

EN variant published?

Set locale = pt

Return 404

Set locale = en

Return 404

Render page

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;
}

Umbraco’s built-in Language Variants feature is used:

  • Each content node stores separate values for pt and en
  • Properties can be set as “vary by culture” or “invariant” (shared across languages)
  • Editors switch between languages in the backoffice using the language selector
Property TypeVaries by Culture?Rationale
Text content (titles, descriptions, body)✅ YesDifferent per language
SEO fields (meta title, meta description)✅ YesUnique per language
URL slug✅ YesDifferent slugs per language (e.g., alojamento vs accommodation)
Media (images)✅ YesPT serves as default; EN override if exists (see section 6)
Numeric values (size, max guests)❌ NoSame value in all languages
Toggles (bookingCta, noIndex)❌ NoSame behavior in all languages
Publish per language✅ YesEach language variant can be published independently. PT can be published without EN, and EN can be published without PT
Content Pickers (related content)❌ NoSame structure in all languages
Configuration (theme, siteKey)❌ NoSite-wide settings

4.3 Content Delivery API Language Handling

Section titled “4.3 Content Delivery API Language Handling”
// Requesting Portuguese content
const ptResponse = await fetch('/umbraco/delivery/api/v2/content/item/alojamento/quarto-deluxe', {
headers: { 'Accept-Language': 'pt', 'Start-Item': 'savoy-palace' },
});
// Requesting English content
const 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" }
// }
// }

RuleDescription
Slugs are manually translatedEditors set the slug for each language in Umbraco
No auto-translationThe CMS does NOT auto-generate EN slugs from PT content
Slug formatLowercase, hyphens, no special characters (kebab-case)
Unique per levelSlugs must be unique among siblings at each tree level
PT SlugEN SlugPage
/pt/alojamento/en/accommodationRooms list
/pt/alojamento/suite-presidencial/en/accommodation/presidential-suiteRoom detail
/pt/restaurantes/galaxia-skyfood/en/restaurants/galaxia-skyfoodRestaurant detail
/pt/sobre-nos/en/about-usAbout page
/pt/contactos/en/contactsContact page

Note: Some slugs (like proper nouns — “Galáxia Skyfood”) may remain the same in both languages.


As defined in 06_Content_Modeling_Umbraco.md, images follow a fallback strategy:

Yes

No, EN

Yes

No

Image requested for locale

locale = PT?

Serve PT image

EN media variant exists?

Serve EN image

Serve PT image (fallback)

packages/cms-client/src/helpers/resolve-media.ts
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;
}
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)

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" }
packages/cms-client/src/dictionary.ts
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 component
const dict = await getDictionary(locale);
// dict.ReadMore → "Read More" or "Ler Mais"

<html lang="pt"> <!-- or lang="en" -->

Every page renders <link rel="alternate" hreflang="..."> for all available language variants:

apps/web/src/lib/metadata.ts
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" />

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>
ScenarioCanonical
PT pageSelf-referencing canonical to PT URL
EN pageSelf-referencing canonical to EN URL
Duplicate contentCanonical to the preferred version

Yes

No

Current: /pt/alojamento/quarto-deluxe

User clicks 'EN'

EN variant exists?

/en/accommodation/deluxe-room

/en/ (EN homepage)

packages/ui/src/LanguageSwitcher/LanguageSwitcher.tsx
'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>
);
}

TypePT FormatEN FormatImplementation
Date04 de Março de 2026March 4, 2026Intl.DateTimeFormat(locale)
Date (short)04/03/202603/04/2026Intl.DateTimeFormat(locale, { dateStyle: 'short' })
Currency€ 250,00€250.00Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' })
Number1.234,561,234.56Intl.NumberFormat(locale)

  • / 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