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 provider
apps/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 routing

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

  1. URL path prefix (/es/dashboard for Spanish)
  2. Browser Accept-Language header
  3. Cookie preference (set when user changes language)

The middleware then:

  1. Validates the locale against supported locales
  2. Redirects to a localized URL if needed
  3. 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:

URLLocaleNotes
/dashboardEnglishDefault locale, no prefix
/es/dashboardSpanishNon-default locales get prefixed
/fr/dashboardFrenchNon-default locales get prefixed
/de/settingsGermanNon-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 configuration
routing.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 context
redirect('/dashboard');
permanentRedirect('/new-path');
// Client-side navigation
const router = useRouter();
router.push('/settings');
router.replace('/settings', { locale: 'es' }); // Switch locale
// Get current pathname without locale prefix
const 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:

NamespacePurposeExample Keys
commonShared UI elementsroutes, roles, otp, language
authAuthentication flowssignIn, signUp, forgotPassword
accountUser profile managementupdateProfile, changePassword
organizationsTeam featurescreateOrganization, inviteMembers
billingPayments and subscriptionsplans, checkout, invoices
marketingPublic pageshero, features, pricing
settingsPreferences and configpersonalSettings, notifications
goodbyeAccount deletionconfirmDelete, feedback
errorsError messagesnotFound, 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

ModeDescriptionExample URLs
as-neededDefault locale has no prefix/about, /es/about
alwaysAll locales have prefix/en/about, /es/about
neverNo 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=en

Server vs Client Components

next-intl works differently in Server and Client Components:

ContextAPINotes
Server ComponentsgetTranslations()Async, direct access to messages
Client ComponentsuseTranslations()Hook, requires provider context
MetadatagetTranslations()Use in generateMetadata()
MiddlewarecreateIntlMiddleware()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?
MakerKit uses next-intl, a popular internationalization library for Next.js that provides full support for React Server Components, streaming, and automatic locale detection. It uses ICU message format for pluralization and formatting.
How do I add a new language to my MakerKit app?
Add the locale code to the locales array in packages/i18n/src/locales.tsx, create translation JSON files in apps/web/i18n/messages/{locale}/, and restart the dev server. The language selector will automatically appear in user settings.
Where are translation files stored?
Translation files are stored in apps/web/i18n/messages/{locale}/ as JSON files. Each namespace (common, auth, billing, etc.) has its own file. Email templates have separate translations in packages/email-templates/src/locales/.
How does locale detection work?
The middleware detects locale from: 1) URL path prefix (/es/dashboard), 2) NEXT_LOCALE cookie from previous preference, 3) Browser Accept-Language header, 4) Default locale fallback. Users can change their preference in settings.
Can I use different URL structures for locales?
Yes, next-intl supports three modes: 'as-needed' (default locale has no prefix), 'always' (all locales have prefix), and 'never' (no prefixes, locale from cookie/header). Configure this in packages/i18n/src/routing.ts.
How do I translate emails?
Email templates use a separate translation system in the @kit/email-templates package. Create JSON files in packages/email-templates/src/locales/{locale}/ and pass the user's language preference when rendering emails.

Next: Setup Guide | Using Translations