The boilerplate includes a comprehensive internationalization system using URL-based routing with BCP 47 locale codes. All text content is stored in JSON message files and accessed through a type-safe translation function.
URL-Based Routing
Locale in URL path: /fr-FR/pricing, /fr-CH/pricing, /en-US/pricing, /en-CA/pricing
JSON Messages
Organized translation files with nested keys and parameter support.
Type-Safe
TypeScript types for locales and translation keys.
Auto-Detection
Browser language detection with cookie persistence.
File Structure
i18n/
├── config.ts # Locale configuration & utilities
├── request.ts # next-intl setup (if used)
└── messages/
├── fr-FR.json # French translations
├── fr-CH.json # Swiss French translations
├── en-US.json # US English translations
└── en-CA.json # Canadian English translations
lib/i18n/
└── index.ts # Translation utilitiesConfiguration
Locale configuration lives in i18n/config.ts. This file defines the list of supported locales (e.g., fr-FR, en-US, en-CA, fr-CH), the default locale, locale display names, flag emojis, and utility functions for locale normalization and language code extraction.
Utility Functions
The i18n/config.ts module provides several helpers: getIntlLocale() converts BCP 47 codes to Intl-compatible formats, getOgLocale() converts to OpenGraph format, normalizeLocale() converts shorthand codes to full locale strings, and getLanguageCode() extracts the language portion.
Using Translations
In server components, use the async getTranslations() function with the locale from URL parameters to get a translation function. Access translations with dot notation (e.g., t('dashboard.title')). In client components, pass the needed translations as props from the parent server component since client components cannot use async functions.
Server Components (Recommended)
Resolve the translation function from the route's locale param and call it with dot-notation keys:
import { getTranslations, type Locale } from '@/lib/i18n'
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
const t = await getTranslations(locale as Locale)
return <h1>{t('dashboard.title')}</h1>
}Client Components
Client components cannot call the async getTranslations(). Resolve the strings in the parent Server Component and pass them down as props:
// Server Component
<SubmitButton translations={{ submit: t('common.submit') }} />
// Client Component ('use client')
function SubmitButton({ translations }: { translations: { submit: string } }) {
return <button>{translations.submit}</button>
}Translation File Structure
Translations are stored as nested JSON files in i18n/messages/, one per locale. Keys are organized by feature area: common (shared strings), dashboard, pricing, auth, chat, etc. Access nested keys with dot notation. To add a new locale, create a new message file, add the locale to the config, and translate all keys.
Locale-Aware Components
The boilerplate provides components that automatically handle locale-based URL routing:
LocaleLink
Use the LocaleLink component instead of Next.js Link for internal navigation. It automatically prepends the current locale to the URL path (e.g., /pricing becomes /fr-FR/pricing). This ensures consistent locale-aware routing throughout the application.
import { LocaleLink } from '@/components/locale-link'
<LocaleLink href="/pricing">{t('nav.pricing')}</LocaleLink>
// renders <a href="/fr-FR/pricing"> on a French routeLanguage Switcher
The language switcher component displays a dropdown with all supported locales (with flag emojis). When a user selects a different language, it preserves the current URL path and replaces only the locale segment. The selected locale is stored in a cookie for persistence across sessions.
Date & Number Formatting
Always use the getIntlLocale() function when formatting dates or numbers with the Intl API. This converts BCP 47 locale codes to the format expected by JavaScript's Intl constructors. For example, use new Date().toLocaleDateString(getIntlLocale(locale)) for date formatting and new Intl.NumberFormat(getIntlLocale(locale), { style: 'currency', currency: 'EUR' }) for currency formatting.
Adding a New Locale
- Add the BCP 47 code to the
localestuple ini18n/config.ts, plus an entry inlocaleNamesandlocaleFlags. - Add the locale→currency mapping to
localeCurrencyMapinconfig/pricing.ts(SUPPORTED_CURRENCIESis derived from it automatically). - Copy an existing message file (e.g.
i18n/messages/en-US.json) toi18n/messages/<locale>.jsonand translate every value — keep the key structure identical. - For multi-locale JSONB content (CMS pages, blog, changelog), add the new locale key to existing rows; reads fall back to
defaultLocalewhen a key is missing. - Run
npm run test—__tests__/i18n/key-parity.test.tsfails the build if the locale files don't have identical key sets.
SEO & Metadata
Localize page metadata in an async generateMetadata() using the same getTranslations(locale) function, and emit hreflang alternates so search engines index every locale. The dynamic app/sitemap.ts already enumerates each locale × route (including CMS pages); app/robots.ts gates indexing behind NEXT_PUBLIC_INDEXABLE.
export async function generateMetadata(
{ params }: { params: Promise<{ locale: string }> }
): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations(locale as Locale)
return {
title: t('pricing.metaTitle'),
alternates: {
languages: { 'fr-FR': '/fr-FR/pricing', 'fr-CH': '/fr-CH/pricing', 'en-US': '/en-US/pricing', 'en-CA': '/en-CA/pricing' },
},
}
}Best Practices
This creates technical debt and breaks when adding new locales.
| Do | Don't |
|---|---|
Use t('key') for all text |
Hardcode text in components |
| Pass translations to client components | Use async getTranslations in client |
Use getIntlLocale() for Intl APIs |
Pass locale directly to Intl |
| Organize keys by feature/page | Flat structure with all keys |
| Use parameters for dynamic values | Concatenate strings |