Guides
Regions and languages
Build a website with several regions or languages: address patterns and the folders for each, reading the right region and language, fallbacks and notices, hreflang, language switchers, previews and cache refresh per region.
How-to guide8 min read
A space can have several regions, such as Germany and Switzerland, each with one or more languages, such as de-DE and en-DE. The CMS keeps their content apart; your website decides how visitors reach each one. This page shows how. It builds on Connect a website.
How the space is set up
Open Configuration › Environments and an environment to see its regions. Each region has:
- a key, such as
germany, used by the API and by webhooks; - a short code, such as
de, which the API also accepts; - an address prefix, such as
/de, when several regions share one domain; - languages, one of them the default, each optionally with a fallback language.
Configuration › Domains says which domain each environment, region and language is published on. The CMS uses it, with the prefix, to build full addresses for canonical links, hreflang and sitemaps. It does not route visitors: your website does.
Read the regions your key may see:
const regions = await cms.getRegions()// [{ key: 'germany', name: 'Germany', locales: [{ code: 'de-DE', name: 'German', isDefault: true }, { code: 'en-DE', name: 'English', isDefault: false }] }]const locales = await cms.getLocales('germany')// [{ code: 'de-DE', name: 'German', isDefault: true }, …]getLocales() without a region uses the client's defaultRegion, or lists every language if there is none. A key limited to some regions or languages only sees those, and gets 401 for the others.
Ask for a region and language
Every page call takes region and locale:
await cms.resolve({ path: '/services', region: 'germany', locale: 'en-DE' })regiondefaultRegion, or the space's first region if that is not setlocale| Option | When left out |
|---|---|
region | The client's defaultRegion, or the space's first region if that is not set |
locale | The region's default language |
The page's address is the same whatever the region prefix: /services, not /de/services. Your route takes the prefix off before asking.
Always pass the region's key, not its short code. Pages are cached under a tag with the region you pass, and webhooks name the region by its key; see Caching and refreshing pages.
Choose an address pattern
example.com/servicesapp/[[...slug]]/page.tsx, as in the tutorialexample.com/en/servicesapp/[lang]/[[...slug]]/page.tsxexample.com/de/servicesapp/[region]/[[...slug]]/page.tsxexample.com/de/en/servicesapp/[region]/[lang]/[[...slug]]/page.tsxexample.de/services, example.ch/servicesapp/[region]/[[...slug]]/page.tsx with a proxy, or one deployment per domain| Pattern | Example | Folders |
|---|---|---|
| One region, one language | example.com/services | app/[[...slug]]/page.tsx, as in the tutorial |
| One region, several languages | example.com/en/services | app/[lang]/[[...slug]]/page.tsx |
| Several regions on one domain | example.com/de/services | app/[region]/[[...slug]]/page.tsx |
| Regions and languages on one domain | example.com/de/en/services | app/[region]/[lang]/[[...slug]]/page.tsx |
| A domain per region | example.de/services, example.ch/services | app/[region]/[[...slug]]/page.tsx with a proxy, or one deployment per domain |
Whichever you choose, set the same prefixes on the regions in the CMS and the same domains in Configuration › Domains, so the addresses the CMS builds match your website's.
Several regions on one domain
Keep a map from the address prefix to the region key in one place:
export const REGIONS = { de: { key: 'germany', lang: 'de-DE' }, ch: { key: 'switzerland', lang: 'de-CH' },} as constexport type RegionPrefix = keyof typeof REGIONSexport function regionFor(prefix: string) { return prefix in REGIONS ? REGIONS[prefix as RegionPrefix] : null}The folders:
app/├── [region]/│ ├── layout.tsx <html lang>, menu and footer for the region│ ├── not-found.tsx│ └── [[...slug]]/│ └── page.tsx├── api/… unchanged└── page.tsx / redirects to the default regionThe layout, with the region's menu and language:
import type { ReactNode } from 'react'import { notFound } from 'next/navigation'import { CmsBridge } from '@bifrost-orbit-cms/sdk/next'import { cms } from '@/lib/cms'import { regionFor } from '@/lib/regions'type Props = { children: ReactNode; params: Promise<{ region: string }> }export default async function RegionLayout({ children, params }: Props) { const region = regionFor((await params).region) if (!region) notFound() const menu = await cms.findNavigation({ key: 'main', region: region.key }).catch(() => null) return ( <html lang={region.lang}> <body> {/* menu from `menu` */} {children} <CmsBridge client={cms} /> </body> </html> )}When each region has its own <html lang>, the region layout is the root layout: remove app/layout.tsx and put <html> and <body> here, as above. Otherwise keep app/layout.tsx and leave <html> out of this one.
The page route:
type Props = { params: Promise<{ region: string; slug?: string[] }> }async function resolveFor({ params }: Props) { const { region: prefix, slug = [] } = await params const region = regionFor(prefix) if (!region) notFound() const { isEnabled } = await draftMode() return load(`/${slug.join('/')}`, region.key, isEnabled)}const load = cache(async (path: string, region: string, preview: boolean) => cms.resolve({ path, region, preview }))The rest of the page route is as in the tutorial. Redirect targets from resolve are addresses within the region, such as /services/new; add the prefix back before redirecting: redirect('/' + prefix + result.to) for addresses starting with /.
The home page:
import { redirect } from 'next/navigation'export default function Home() { redirect('/ie')}To send visitors to their own region instead, read the Accept-Language header or a country header from your hosting in proxy.ts, and redirect / there.
Links between pages: links and menu items from the CMS carry addresses within the region, such as /services. Add the region prefix when rendering them, in one helper used by every link component:
export const withRegion = (prefix: string, href: string) => (href.startsWith('/') ? `/${prefix}${href === '/' ? '' : href}` : href)Several languages
Map the address segment to a language code the same way:
export const LANGUAGES = { de: 'de-DE', en: 'en-DE' } as constand pass it as locale:
await cms.resolve({ path, region: 'germany', locale: LANGUAGES[lang] })The default language can live at the root, without a prefix, by rewriting in proxy.ts: requests without a language prefix are rewritten to /de/… internally, so app/[lang] serves them while the address stays /services.
import { NextResponse, type NextRequest } from 'next/server'const PREFIXES = ['de', 'en']export function proxy(request: NextRequest) { const { pathname } = request.nextUrl if (PREFIXES.some((prefix) => pathname === `/${prefix}` || pathname.startsWith(`/${prefix}/`))) return NextResponse.next() return NextResponse.rewrite(new URL(`/de${pathname}`, request.url))}export const config = { matcher: ['/((?!api/|_next/|.*\\..*).*)'] }If you also need the preview redirect from the tutorial, do both in one proxy function: check for ?preview= first.
A domain per region
Two ways:
- One deployment per domain. Deploy the same code twice, with
CMS_DEFAULT_REGION=germanyon one andCMS_DEFAULT_REGION=switzerlandon the other, and the single-region folders from the tutorial. Simplest; each deployment has its own webhook. - One deployment for every domain. Keep
app/[region]/…and rewrite by host name inproxy.ts, soexample.de/servicesis served by/de/servicesinternally and stays cached:
const HOSTS: Record<string, string> = { 'www.example.de': 'de', 'www.example.ch': 'ch' }export function proxy(request: NextRequest) { const prefix = HOSTS[request.headers.get('host') ?? ''] if (!prefix) return NextResponse.next() return NextResponse.rewrite(new URL(`/${prefix}${request.nextUrl.pathname}`, request.url))}With rewrites, links stay without a prefix: skip withRegion.
Fallbacks and notices
When a page is not translated, the API can answer with another language, and when a region does not have a page, with another region. The space's settings decide; see Space settings. Every page says what happened:
page.meta.requestedLocale // 'en-DE', what you asked forpage.meta.locale // 'de-DE', what answeredpage.meta.fallbackApplied // true: another language answeredpage.meta.requestedRegion // 'germany'page.meta.region // 'germany'page.meta.regionFallbackApplied // falseShow a notice so visitors know:
{page.meta.fallbackApplied ? <p role="note">This page is not available in English yet, so it is shown in German.</p> : null}Set lang on the content to the language that answered, so screen readers pronounce it correctly: <main lang={page.locale}>.
To refuse fallbacks for a request, pass strict: true. A page missing in the language or region asked for then answers notFound. Use it for content where showing another language would be wrong, such as legal terms:
await cms.resolve({ path, region: 'germany', locale: 'en-DE', strict: true })Globals follow the language fallback but not the region fallback, and lists never fall back. If a list is empty in a language, read it in the default language yourself.
hreflang and language switchers
toMetadata(page) adds alternates.languages from page.seo.alternates, so Next.js writes a <link rel="alternate" hreflang> for each language and region the page exists in. Nothing else is needed for search engines. Which versions are linked, and how x-default is chosen, is in Languages, regions and search engines; SEO health checks the live pages about once a day.
The same list builds a language switcher:
import { hreflang } from '@bifrost-orbit-cms/sdk/seo'const alternates = hreflang(page) // { 'de-DE': 'https://www.example.com/de/services', 'en-DE': 'https://www.example.com/de/en/services' }<ul> {page.seo.alternates.map((entry) => ( <li key={entry.hreflang}> <a href={new URL(entry.href).pathname} hrefLang={entry.hreflang} lang={entry.hreflang}> {entry.hreflang} </a> </li> ))}</ul>The addresses are built from Configuration › Domains and the region prefixes, so they are only right when those match your website. Use the path only, as above, so the switcher also works on staging.
Give the languages their own names with getLocales(region): each has code and name.
Previews
The editor opens the Preview URL from the space's settings followed by the page's address, which has no region prefix. For a website with region prefixes:
- Set Preview URL to the default region, such as
https://staging.example.com/de. - Add each other region under Other preview URLs, such as
https://staging.example.com/ch, named after the region. - When editing a page in another region, choose its address in the list above the preview.
The preview key must be allowed to read every region editors preview.
Sitemaps and robots
Each region and language gets its own sitemap at its own address, such as /de/sitemap.xml, listed in a sitemap index. See Metadata, sitemaps and robots.
Cache refresh
Webhook messages name each page's region key, and the SDK refreshes cms:page:<region>:<path>. As long as your website passes the region key, publishing a page in one region refreshes only that region's copy. Pages shown through the region fallback catch up on expiry; see Caching and refreshing pages.
Was this page helpful?