Step 8 of 10 (80% complete)
Multi-Language Support
Step Code
The code for this specific step can be found on the following branch:
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:
- Locale in URL path — if the request is to
/pl/about, the locale ispl - Cookie (
__LOCALE_NAME) — if the user has previously selected a language, that preference is stored in a cookie - Accept-Language header — the browser sends its preferred language, and the middleware uses the
negotiatorpackage to match it against the supported locales - 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/aboutbut internally Next.js serves/en/about - For non-default locales, it uses
NextResponse.redirect— the URL becomes/pl/aboutand 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!