Skip to content

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:

TypeScript
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:

TypeScript
await cms.resolve({ path: '/services', region: 'germany', locale: 'en-DE' })
region
When left out
The client's defaultRegion, or the space's first region if that is not set
locale
When left out
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

One region, one language
Example
example.com/services
Folders
app/[[...slug]]/page.tsx, as in the tutorial
One region, several languages
Example
example.com/en/services
Folders
app/[lang]/[[...slug]]/page.tsx
Several regions on one domain
Example
example.com/de/services
Folders
app/[region]/[[...slug]]/page.tsx
Regions and languages on one domain
Example
example.com/de/en/services
Folders
app/[region]/[lang]/[[...slug]]/page.tsx
A domain per region
Example
example.de/services, example.ch/services
Folders
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:

lib/regions.tsTypeScript
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:

Text
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 region

The layout, with the region's menu and language:

app/[region]/layout.tsxTypeScript
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:

app/[region]/[[...slug]]/page.tsxTypeScript
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:

app/page.tsxTypeScript
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:

TypeScript
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:

lib/languages.tsTypeScript
export const LANGUAGES = { de: 'de-DE', en: 'en-DE' } as const

and pass it as locale:

TypeScript
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.

proxy.tsTypeScript
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=germany on one and CMS_DEFAULT_REGION=switzerland on 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 in proxy.ts, so example.de/services is served by /de/services internally and stays cached:
proxy.tsTypeScript
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:

TypeScript
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 // false

Show a notice so visitors know:

TypeScript
{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:

TypeScript
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:

TypeScript
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:

  1. Set Preview URL to the default region, such as https://staging.example.com/de.
  2. Add each other region under Other preview URLs, such as https://staging.example.com/ch, named after the region.
  3. 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?

Read next