Localization with destinations

Destinations don’t manage locales. Each path maps to a single experience. The Destinations Delivery API doesn’t support locale-aware routing, a locale parameter, or automatic redirects based on browser language.

Your application must resolve the locale before requesting an experience. You can store the locale in the URL path, the domain, or a cookie.

Your application is responsible for:

  • Determining the locale from the incoming request — URL path prefix, domain, etc.
  • Removing the locale prefix from the path before calling the Destinations Delivery API.
  • Rendering the hydrated experience returned by the API.

How it works

Incoming request Your handler Destination Delivery API
───────────────────── ────────────────────────── ──────────────────────────
/de/products/shoes ──► extract locale: "de" ──► POST /destinations/:id/experiences
strip prefix: /de body: { "path": "/products/shoes" }
render experience ◄── { experiences: [{ experience: {...} }] }

The Destination API resolves the path and returns either a hydrated experience or a redirect. Your request handler must check for a redirect before rendering the experience.

Pattern 1: Single-segment path prefix

The most common setup. One segment at the start of the URL identifies the locale. Remove the locale segment before resolving the experience.

/en/products/shoes → locale: en, path: /products/shoes
/de/products/shoes → locale: de, path: /products/shoes
/fr/about → locale: fr, path: /about

Next.js example

// app/[locale]/[...slug]/page.tsx
const SUPPORTED_LOCALES = ['en', 'de', 'fr'];
export default async function Page({ params }: { params: { locale: string; slug: string[] } }) {
const { locale, slug } = params;
if (!SUPPORTED_LOCALES.includes(locale)) {
notFound();
}
const destinationPath = '/' + slug.join('/');
const res = await fetch(
`https://xdn.contentful.com/spaces/${SPACE_ID}/destinations/${DESTINATION_ID}/experiences`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${ACCESS_TOKEN}`,
},
body: JSON.stringify({ path: destinationPath }),
},
);
const data = await res.json();
if ('redirect' in data) {
redirect(`/${locale}${data.redirect.path}`);
}
const experience = data.experiences[0].experience;
// render experience using your ExO rendering setup, passing locale as needed
}

Pattern 2: Language-region prefix

Same as Pattern 1, but the segment encodes both language and region. The extraction logic is identical — the segment just has a different shape.

/en-us/products/shoes → locale: en-us, path: /products/shoes
/de-ch/products/shoes → locale: de-ch, path: /products/shoes
// app/[locale]/[...slug]/page.tsx
const LOCALE_PATTERN = /^[a-z]{2}-[a-z]{2}$/; // matches "en-us", "de-ch"
export default async function Page({ params }: { params: { locale: string; slug: string[] } }) {
const { locale, slug } = params;
if (!LOCALE_PATTERN.test(locale)) notFound();
const destinationPath = '/' + slug.join('/');
// rest is the same as Pattern 1
}

Pattern 3: Two-segment country/language prefix

Some sites use two separate segments for country and language. Remove both before calling the API.

/de/de/products/shoes → country: de, lang: de, path: /products/shoes
/ca/fr/products/shoes → country: ca, lang: fr, path: /products/shoes
// app/[country]/[lang]/[...slug]/page.tsx
export default async function Page({
params,
}: {
params: { country: string; lang: string; slug: string[] };
}) {
const { country, lang, slug } = params;
const destinationPath = '/' + slug.join('/');
// rest is the same as Pattern 1
}

Pattern 4: Domain or subdomain locale

Use this pattern when each locale has a dedicated domain or subdomain. Pass the request path to the Destination API unchanged — no need to remove the locale prefix. Determine the locale from the incoming request’s host. Depending on your hosting configuration, read the host from the Host header, the X-Forwarded-Host header, or a platform-specific equivalent.

acme.de/products/shoes → locale: de, path: /products/shoes
acme.co.uk/products/shoes → locale: en, path: /products/shoes
fr.acme.com/about → locale: fr, path: /about
// app/[...slug]/page.tsx
import { headers } from 'next/headers';
const DOMAIN_LOCALE_MAP: Record<string, string> = {
'acme.com': 'en',
'acme.de': 'de',
'acme.co.uk': 'en',
'fr.acme.com': 'fr',
};
export default async function Page({ params }: { params: { slug: string[] } }) {
const host = headers().get('host') ?? '';
const locale = DOMAIN_LOCALE_MAP[host] ?? 'en';
const destinationPath = '/' + params.slug.join('/');
// rest is the same as Pattern 1, except no locale prefix on redirect:
// redirect(data.redirect.path)
}

Sitemap and hreflang

The destination sitemap endpoint returns locale-agnostic paths:

curl --request GET \
--url "https://xdn.contentful.com/spaces/{space_id}/destinations/{destination_id}/sitemap" \
--header "Authorization: Bearer {access_token}"
{
"paths": {
"items": [
{ "path": "/products/shoes", "experienceUrn": "crn:..." },
{ "path": "/about", "experienceUrn": "crn:..." }
]
}
}

To generate a locale-aware XML sitemap, expand each path into one entry per locale and add hreflang alternates so search engines understand the relationship between them.

Path-prefix locales (Patterns 1, 2, and 3)

async function generateSitemap(locales: string[]) {
const sitemapResponse = await fetchDestinationSitemap();
return sitemapResponse.paths.items.flatMap((item) =>
locales.map((locale) => ({
url: `https://acme.com/${locale}${item.path}`,
alternates: locales.map((alt) => ({
hreflang: alt,
href: `https://acme.com/${alt}${item.path}`,
})),
})),
);
}

This produces one <url> block per locale per path, each annotated with alternates for every other locale:

<url>
<loc>https://acme.com/en/products/shoes</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://acme.com/en/products/shoes"/>
<xhtml:link rel="alternate" hreflang="de" href="https://acme.com/de/products/shoes"/>
</url>
<url>
<loc>https://acme.com/de/products/shoes</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://acme.com/en/products/shoes"/>
<xhtml:link rel="alternate" hreflang="de" href="https://acme.com/de/products/shoes"/>
</url>

Domain-based locales (Pattern 4)

const DOMAIN_LOCALE_MAP: Record<string, string> = {
'acme.com': 'en',
'acme.de': 'de',
'acme.co.uk': 'en',
'fr.acme.com': 'fr',
};
async function generateSitemap() {
const sitemapResponse = await fetchDestinationSitemap();
return sitemapResponse.paths.items.flatMap((item) =>
Object.entries(DOMAIN_LOCALE_MAP).map(([domain, locale]) => ({
url: `https://${domain}${item.path}`,
alternates: Object.entries(DOMAIN_LOCALE_MAP).map(([altDomain, altLocale]) => ({
hreflang: altLocale,
href: `https://${altDomain}${item.path}`,
})),
})),
);
}

This produces the same hreflang structure, but with full domain URLs:

<url>
<loc>https://acme.com/products/shoes</loc>
<xhtml:link rel="alternate" hreflang="en" href="https://acme.com/products/shoes"/>
<xhtml:link rel="alternate" hreflang="de" href="https://acme.de/products/shoes"/>
<xhtml:link rel="alternate" hreflang="en" href="https://acme.co.uk/products/shoes"/>
<xhtml:link rel="alternate" hreflang="fr" href="https://fr.acme.com/products/shoes"/>
</url>