Internationalization

Multi-language support architecture using next-intl in your SaaS application.

This guide is part of the Drizzle stack docs.

MakerKit uses next-intl for internationalization with full App Router support. The system provides server-side rendering, namespace-based code splitting, automatic locale detection, and type-safe translations.

By default, only English is enabled. Adding additional languages requires uncommenting locale codes and creating translation files for each language.

How It Works

The i18n flow in MakerKit:

  1. Middleware detects locale from URL, cookies, or browser headers
  2. Dynamic route [locale] captures the locale parameter
  3. Layout validates the locale and loads messages
  4. Provider makes translations available to all components
  5. Components access translations via hooks or the Trans component

Architecture

The i18n system is split between shared infrastructure and app-specific configuration:

packages/i18n/ # Shared i18n package
├── src/
│ ├── routing.ts # Routing configuration
│ ├── navigation.ts # Locale-aware Link, redirect, useRouter
│ ├── locales.tsx # Supported locales array
│ ├── default-locale.ts # Default locale from env
│ └── client-provider.tsx # NextIntlClientProvider wrapper
apps/web/
├── proxy.ts # Middleware with i18n routing
├── app/[locale]/layout.tsx # Root layout with locale validation
└── i18n/
├── request.ts # Namespace definitions and loading
└── messages/
└── en/ # English translations
├── common.json
├── auth.json
├── account.json
├── organizations.json
├── billing.json
├── marketing.json
├── settings.json
├── goodbye.json
└── errors.json

Middleware Configuration

The middleware in apps/web/proxy.ts handles i18n routing:

import createNextIntlMiddleware from 'next-intl/middleware';
import { routing } from '@kit/i18n/routing';
async function proxy(request: NextRequest) {
const handleI18nRouting = createNextIntlMiddleware(routing);
const response = handleI18nRouting(request);
// ... additional middleware logic
return response;
}

The middleware:

  • Detects locale from URL path, cookies, or Accept-Language header
  • Redirects to the appropriate locale URL when needed
  • Sets locale cookies for persistence

Locale Configuration

Locales are configured in packages/i18n/src/locales.tsx:

import { defaultLocale } from './default-locale';
export const locales: string[] = [
defaultLocale,
// Uncomment to enable additional locales:
// 'es', // Spanish
// 'fr', // French
];

The default locale is read from an environment variable in packages/i18n/src/default-locale.ts:

export const defaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? 'en';

Routing Configuration

The routing is defined in packages/i18n/src/routing.ts:

import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales,
defaultLocale,
localePrefix: 'as-needed',
localeDetection: true,
});
export type Locale = (typeof routing.locales)[number];

Settings explained:

SettingValueEffect
localePrefix'as-needed'Default locale has no URL prefix, others do
localeDetectiontrueAuto-detect from browser headers/cookies

URL Structure

With localePrefix: 'as-needed':

URLLocaleNotes
/aboutEnglish (default)No prefix needed
/es/aboutSpanishPrefix required
/fr/aboutFrenchPrefix required

Root Layout

The locale layout at apps/web/app/[locale]/layout.tsx validates the locale and loads messages. Here's a simplified view of the key i18n parts:

import { hasLocale } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '@kit/i18n/routing';
export default async function LocaleLayout({ children, params }) {
const { locale } = await params;
// Validate locale is supported
if (!hasLocale(routing.locales, locale)) {
notFound();
}
// Load all translation messages for this locale
const messages = await getMessages({ locale });
return (
<html lang={locale}>
<body>
<RootProviders locale={locale} messages={messages}>
{children}
</RootProviders>
</body>
</html>
);
}

The actual implementation includes additional features like theme handling, CSP nonce management, and font configuration. The pattern above shows the essential i18n setup.

Client Provider

The RootProviders component wraps the app with I18nClientProvider:

import { I18nClientProvider } from '@kit/i18n/provider';
export function RootProviders({ locale, messages, children }) {
return (
<I18nClientProvider locale={locale} messages={messages}>
{children}
</I18nClientProvider>
);
}

This makes translations available to all client components via hooks.

Package Exports

The @kit/i18n package provides these entry points:

@kit/i18n/routing

Locale configuration and routing:

import {
routing,
locales,
defaultLocale,
type Locale
} from '@kit/i18n/routing';

@kit/i18n/navigation

Locale-aware navigation utilities that automatically handle locale prefixes:

import {
Link,
redirect,
permanentRedirect,
useRouter,
usePathname
} from '@kit/i18n/navigation';

These work like their Next.js counterparts but automatically prepend the current locale to paths.

@kit/i18n/provider

Client-side provider for components:

import { I18nClientProvider } from '@kit/i18n/provider';

Namespaces

Translation files are organized by namespace. Each namespace corresponds to a JSON file in apps/web/i18n/messages/{locale}/.

Namespaces are defined in apps/web/i18n/request.ts:

const namespaces = [
'common',
'auth',
'account',
'organizations',
'billing',
'marketing',
'settings',
'goodbye',
'errors',
] as const;
NamespacePurpose
commonShared UI labels, routes, roles, OTP, cookie banner, doc search
authSign up, sign in, password reset, MFA flows
accountProfile updates, email/password management
organizationsTeam management, invitations, member actions
billingSubscriptions, plans, billing portal
marketingLanding page, blog, FAQ, contact forms
settingsPersonal and organization settings pages
goodbyeAccount deletion confirmation
errorsError messages and status codes

Environment Variables

VariableDefaultDescription
NEXT_PUBLIC_DEFAULT_LOCALE'en'Default language code

Error Handling

Missing translations are handled gracefully:

  • In development: warnings are logged to the console
  • In production: errors are silently ignored
  • Fallback: the translation key is returned as-is

This is configured in apps/web/i18n/request.ts:

export default getRequestConfig(async ({ requestLocale }) => {
return {
locale,
messages,
timeZone: 'UTC',
onError(error) {
if (isDevelopment) {
console.warn(`[Dev Only] i18n error: ${error.message}`);
}
},
getMessageFallback(info) {
return info.key; // Return key as fallback
},
};
});

Next: Using Translations →