Step 8 of 10 (80% complete)

Multi-Language Support

Step Code

The code for this specific step can be found on the following branch:

Click on a link to view the code for this step on GitHub.

The starter ships with built-in support for multiple languages. The supported locales are en, pl, and sv, but adding or removing a locale takes about five minutes.

Locale Configuration

All locale configuration lives in lib/optimizely/language.ts:

export const DEFAULT_LOCALE = 'en'
export const LOCALES = ['en', 'pl', 'sv']

To add a new locale, add it to the LOCALES array, add it in your Optimizely CMS instance settings, and add its display name to the LOCALE_NAMES map in components/layout/language-switcher.tsx. That is all.

How the Routing Works

The URL structure is:

  • English (default locale): /about — no locale prefix, stays clean
  • Polish: /pl/about — locale prefix added
  • Swedish: /sv/about — locale prefix added

This is handled by the Next.js middleware in proxy.ts. The middleware runs on every request and determines the correct locale.

Locale Detection Priority

The middleware detects the locale in this order:

  1. Locale in URL path — if the request is to /pl/about, the locale is pl
  2. Cookie (__LOCALE_NAME) — if the user has previously selected a language, that preference is stored in a cookie
  3. Accept-Language header — the browser sends its preferred language, and the middleware uses the negotiator package to match it against the supported locales
  4. Default locale — fallback to en
function getLocale(request: NextRequest, locales: string[]): string {
  const cookieLocale = request.cookies.get('__LOCALE_NAME')?.value
  if (cookieLocale && locales.includes(cookieLocale)) return cookieLocale

  const browserLang = getBrowserLanguage(request, locales)
  if (browserLang) return browserLang

  return DEFAULT_LOCALE
}

The middleware also handles the difference between the default and non-default locales:

  • For the default locale, it uses NextResponse.rewrite — the URL stays as /about but internally Next.js serves /en/about
  • For non-default locales, it uses NextResponse.redirect — the URL becomes /pl/about and the browser's address bar updates

Content Fetching Per Locale

Every content fetch includes the locale in the path:

client.getContentByPath(`/${locale}/${slug.join('/')}/`)

Optimizely Graph returns the localized version of the content based on the path prefix. If a translation does not exist for a given locale, the Graph falls back to the default language — this is configurable in your CMS instance.

Static Generation for All Locales

The locale layout generates static params for all supported locales:

export function generateStaticParams() {
  return LOCALES.map((locale) => ({ locale }))
}

Combined with generateStaticParams() in the catch-all page (which fetches all page paths from CMS), Next.js pre-renders every page in every language at build time. The result is a fully static, multi-language site with instant page loads.

SEO: hreflang Alternates

The generateAlternates() helper in lib/metadata.ts generates the correct hreflang tags for every page:

export async function generateMetadata(props: Props): Promise<Metadata> {
  const { locale, slug } = await props.params
  return {
    title: c?.title ?? '',
    alternates: generateAlternates(locale, `/${slug.join('/')}/`),
    // generates: { canonical: '/en/about', languages: { en: '/en/about', pl: '/pl/about', sv: '/sv/about' } }
  }
}

This tells search engines which pages are translations of each other, which is critical for multi-language SEO.

Have questions? I'm here to help!

Contact Me