Internationalization
Add multi-language support to your SaaS application using next-intl with server-side rendering, namespace-based organization, and automatic locale detection.
MakerKit uses next-intl for internationalization, providing type-safe translations with full support for React Server Components, streaming, and automatic locale detection.
To add a new language: enable the locale in packages/i18n/src/locales.tsx, create translation JSON files in apps/web/i18n/messages/{locale}/, and restart the dev server. The middleware handles locale detection from URLs, cookies, and browser headers automatically.
This page covers the architecture, configuration, and core concepts. For implementation details, see Setup Guide, Using Translations, and Managing Translations.
Architecture
The i18n system separates shared infrastructure from app-specific translations:
packages/i18n/ # Shared i18n package├── src/│ ├── routing.ts # Routing configuration│ ├── navigation.ts # Locale-aware navigation utilities│ ├── default-locale.ts # Default locale configuration│ ├── locales.tsx # Supported locales array│ └── client-provider.tsx # Client-side providerapps/web/├── i18n/│ ├── request.ts # Namespace definitions and message loading│ └── messages/│ └── en/ # English translations (add more locales)│ ├── common.json # Shared UI labels│ ├── auth.json # Authentication│ ├── account.json # Account management│ ├── organizations.json # Team features│ ├── billing.json # Billing and subscriptions│ ├── marketing.json # Marketing pages│ ├── settings.json # Settings pages│ ├── goodbye.json # Account deletion│ └── errors.json # Error messages└── proxy.ts # Middleware with i18n routingWhy this structure? The shared package (@kit/i18n) provides reusable utilities across the monorepo, while app-specific translations stay in apps/web/i18n. This separation lets you customize translations per app without modifying shared code.
How It Works
When a request comes in, the middleware detects the locale from:
- URL path prefix (
/es/dashboardfor Spanish) - Browser
Accept-Languageheader - Cookie preference (set when user changes language)
The middleware then:
- Validates the locale against supported locales
- Redirects to a localized URL if needed
- Sets headers for downstream components
In the root layout, getMessages() loads all translation namespaces for the detected locale, and RootProviders wraps the app with the i18n context.
import { getMessages } from 'next-intl/server';import { RootProviders } from '@components/root-providers';export default async function LocaleLayout({ children, params }) { const { locale } = await params; const messages = await getMessages({ locale }); return ( <html lang={locale}> <body> <RootProviders locale={locale} messages={messages}> {children} </RootProviders> </body> </html> );}URL Structure
With localePrefix: 'as-needed', the default locale has no URL prefix:
| URL | Locale | Notes |
|---|---|---|
/dashboard | English | Default locale, no prefix |
/es/dashboard | Spanish | Non-default locales get prefixed |
/fr/dashboard | French | Non-default locales get prefixed |
/de/settings | German | Non-default locales get prefixed |
This keeps URLs clean for your primary market while supporting additional languages.
Package Exports
The @kit/i18n package provides several entry points:
@kit/i18n/routing
Locale configuration and type definitions:
import { routing, type Locale } from '@kit/i18n/routing';// Access configurationrouting.locales; // ['en', 'es', 'fr']routing.defaultLocale; // 'en'routing.localePrefix; // 'as-needed'@kit/i18n/navigation
Locale-aware navigation utilities that automatically handle prefixes:
import { Link, redirect, useRouter, usePathname, permanentRedirect } from '@kit/i18n/navigation';// Link automatically adds locale prefix when needed<Link href="/settings">Settings</Link>// Redirects work with locale contextredirect('/dashboard');permanentRedirect('/new-path');// Client-side navigationconst router = useRouter();router.push('/settings');router.replace('/settings', { locale: 'es' }); // Switch locale// Get current pathname without locale prefixconst pathname = usePathname(); // '/dashboard' not '/es/dashboard'@kit/i18n/provider
Client-side provider for translation context:
import { I18nClientProvider } from '@kit/i18n/provider';<I18nClientProvider locale={locale} messages={messages}> {children}</I18nClientProvider>Namespaces
Translations are organized into namespaces for code splitting and maintainability:
| Namespace | Purpose | Example Keys |
|---|---|---|
common | Shared UI elements | routes, roles, otp, language |
auth | Authentication flows | signIn, signUp, forgotPassword |
account | User profile management | updateProfile, changePassword |
organizations | Team features | createOrganization, inviteMembers |
billing | Payments and subscriptions | plans, checkout, invoices |
marketing | Public pages | hero, features, pricing |
settings | Preferences and config | personalSettings, notifications |
goodbye | Account deletion | confirmDelete, feedback |
errors | Error messages | notFound, serverError, validation |
Each namespace maps to a JSON file: apps/web/i18n/messages/{locale}/{namespace}.json
Configuration Reference
Routing Configuration
Located in packages/i18n/src/routing.ts:
import { defineRouting } from 'next-intl/routing';export const routing = defineRouting({ // Supported locales locales, // Default locale (no URL prefix) defaultLocale, // URL prefix strategy localePrefix: 'as-needed', // Auto-detect from browser localeDetection: true,});Available Locale Prefix Modes
| Mode | Description | Example URLs |
|---|---|---|
as-needed | Default locale has no prefix | /about, /es/about |
always | All locales have prefix | /en/about, /es/about |
never | No locale prefixes | /about (locale from cookie/header) |
Supported Locales
Defined in packages/i18n/src/locales.tsx:
import { defaultLocale } from './default-locale';export const locales: string[] = [ defaultLocale, // Add more locales: // 'es', // Spanish // 'fr', // French // 'de', // German];Default Locale
Configured via environment variable or fallback:
export const defaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? 'en';Set in your .env file:
NEXT_PUBLIC_DEFAULT_LOCALE=enServer vs Client Components
next-intl works differently in Server and Client Components:
| Context | API | Notes |
|---|---|---|
| Server Components | getTranslations() | Async, direct access to messages |
| Client Components | useTranslations() | Hook, requires provider context |
| Metadata | getTranslations() | Use in generateMetadata() |
| Middleware | createIntlMiddleware() | Handles locale detection/routing |
See Using Translations for detailed examples.
Testing Considerations
When testing components with translations:
import { NextIntlClientProvider } from 'next-intl';import messages from '@/i18n/messages/en/common.json';function renderWithI18n(component: React.ReactNode) { return render( <NextIntlClientProvider locale="en" messages={{ common: messages }}> {component} </NextIntlClientProvider> );}Common Patterns
Conditional Content by Locale
import { getLocale } from 'next-intl/server';async function PricingPage() { const locale = await getLocale(); // Show different pricing for different regions const prices = locale === 'en' ? { monthly: '$19', annual: '$190' } : { monthly: '17', annual: '170' }; return <PricingTable prices={prices} currency={locale === 'en' ? 'USD' : 'EUR'} />;}Locale-Specific Formatting
import { getLocale } from 'next-intl/server';async function DateDisplay({ date }: { date: Date }) { const locale = await getLocale(); const formatted = new Intl.DateTimeFormat(locale, { dateStyle: 'long', }).format(date); return <time dateTime={date.toISOString()}>{formatted}</time>;}Frequently Asked Questions
What i18n library does MakerKit use?
How do I add a new language to my MakerKit app?
Where are translation files stored?
How does locale detection work?
Can I use different URL structures for locales?
How do I translate emails?
Next: Setup Guide | Using Translations