Internationalization in Next.js: Complete Implementation Guide
After implementing internationalization for a global SaaS platform supporting 12 languages and 30+ regions, I learned that proper i18n goes far beyond translating text. Here's the complete implementation guide that scaled our platform to serve 500,000+ users across different cultures and languages.
The i18n Architecture That Scales
Most developers start with simple text translation, but production i18n requires handling currencies, dates, numbers, RTL layouts, SEO, and automated workflows. Here's the architecture I've refined:
// types/i18n.ts
export interface Locale {
code: string;
name: string;
dir: 'ltr' | 'rtl';
currency: string;
region: string;
fallback?: string;
}
export const locales: Locale[] = [
{ code: 'en-US', name: 'English (US)', dir: 'ltr', currency: 'USD', region: 'US' },
{ code: 'en-GB', name: 'English (UK)', dir: 'ltr', currency: 'GBP', region: 'GB' },
{ code: 'es-ES', name: 'Español', dir: 'ltr', currency: 'EUR', region: 'ES' },
{ code: 'es-MX', name: 'Español (México)', dir: 'ltr', currency: 'MXN', region: 'MX' },
{ code: 'fr-FR', name: 'Français', dir: 'ltr', currency: 'EUR', region: 'FR' },
{ code: 'de-DE', name: 'Deutsch', dir: 'ltr', currency: 'EUR', region: 'DE' },
{ code: 'ja-JP', name: '日本語', dir: 'ltr', currency: 'JPY', region: 'JP' },
{ code: 'ar-SA', name: 'العربية', dir: 'rtl', currency: 'SAR', region: 'SA', fallback: 'ar' },
{ code: 'zh-CN', name: '简体中文', dir: 'ltr', currency: 'CNY', region: 'CN' },
{ code: 'pt-BR', name: 'Português (Brasil)', dir: 'ltr', currency: 'BRL', region: 'BR' }
];
export const defaultLocale = 'en-US';
export const localePrefix = 'as-needed' as const;
Next-Intl Configuration for Production
I chose next-intl over react-i18next for its Next.js-specific optimizations and better TypeScript support:
// i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
import { createSharedPathnamesNavigation } from 'next-intl/navigation';
export const routing = defineRouting({
locales: ['en-US', 'es-ES', 'fr-FR', 'de-DE', 'ja-JP', 'ar-SA'],
defaultLocale: 'en-US',
pathnames: {
'/': '/',
'/about': {
'en-US': '/about',
'es-ES': '/acerca-de',
'fr-FR': '/a-propos',
'de-DE': '/uber-uns',
'ja-JP': '/について',
'ar-SA': '/حول'
},
'/products': {
'en-US': '/products',
'es-ES': '/productos',
'fr-FR': '/produits',
'de-DE': '/produkte',
'ja-JP': '/製品',
'ar-SA': '/المنتجات'
},
'/products/[slug]': {
'en-US': '/products/[slug]',
'es-ES': '/productos/[slug]',
'fr-FR': '/produits/[slug]',
'de-DE': '/produkte/[slug]',
'ja-JP': '/製品/[slug]',
'ar-SA': '/المنتجات/[slug]'
}
}
});
export const { Link, redirect, usePathname, useRouter } =
createSharedPathnamesNavigation(routing);
// i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ locale }) => {
// Validate that the incoming `locale` parameter is valid
if (!routing.locales.includes(locale as any)) {
notFound();
}
return {
messages: await importMessages(locale),
timeZone: getTimeZoneForLocale(locale),
now: new Date(),
formats: {
dateTime: {
short: {
day: 'numeric',
month: 'short',
year: 'numeric'
},
long: {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
}
},
number: {
currency: {
style: 'currency',
currency: getCurrencyForLocale(locale)
}
}
}
};
});
async function importMessages(locale: string) {
try {
const messages = await import(`../messages/${locale}.json`);
return messages.default;
} catch (error) {
// Fallback to English if locale messages don't exist
const fallback = await import('../messages/en-US.json');
return fallback.default;
}
}
Middleware for Smart Locale Detection
My middleware handles complex scenarios like subdomain-based locales and user preferences:
// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { NextRequest, NextResponse } from 'next/server';
import { routing } from './i18n/routing';
const intlMiddleware = createMiddleware(routing);
export default async function middleware(request: NextRequest) {
const { pathname, searchParams } = request.nextUrl;
// Skip middleware for API routes, static files, and admin
if (
pathname.startsWith('/api') ||
pathname.startsWith('/_next') ||
pathname.startsWith('/admin') ||
pathname.includes('.')
) {
return NextResponse.next();
}
// Handle subdomain-based locales
const host = request.headers.get('host') || '';
const subdomain = host.split('.')[0];
const subdomainLocale = getLocaleFromSubdomain(subdomain);
if (subdomainLocale) {
const response = NextResponse.rewrite(
new URL(`/${subdomainLocale}${pathname}`, request.url)
);
response.cookies.set('NEXT_LOCALE', subdomainLocale, {
maxAge: 31536000, // 1 year
httpOnly: false,
sameSite: 'lax'
});
return response;
}
// Handle forced locale from query params
const forceLocale = searchParams.get('locale');
if (forceLocale && routing.locales.includes(forceLocale as any)) {
const response = NextResponse.redirect(
new URL(`/${forceLocale}${pathname}`, request.url)
);
response.cookies.set('NEXT_LOCALE', forceLocale, {
maxAge: 31536000,
httpOnly: false,
sameSite: 'lax'
});
return response;
}
// Apply next-intl middleware
const response = intlMiddleware(request);
// Add security headers for different locales
if (response) {
const locale = getLocaleFromPathname(pathname);
if (locale === 'ar-SA') {
response.headers.set('Content-Security-Policy',
"default-src 'self'; style-src 'self' 'unsafe-inline' fonts.googleapis.com; font-src fonts.gstatic.com;"
);
}
}
return response;
}
function getLocaleFromSubdomain(subdomain: string): string | null {
const subdomainMap: Record<string, string> = {
'fr': 'fr-FR',
'es': 'es-ES',
'de': 'de-DE',
'ja': 'ja-JP',
'ar': 'ar-SA'
};
return subdomainMap[subdomain] || null;
}
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
};
Message Organization and Type Safety
I organize messages by feature/page and maintain type safety:
// lib/message-loader.ts
interface MessageNamespace {
common: typeof import('../messages/en-US/common.json');
auth: typeof import('../messages/en-US/auth.json');
dashboard: typeof import('../messages/en-US/dashboard.json');
products: typeof import('../messages/en-US/products.json');
billing: typeof import('../messages/en-US/billing.json');
}
// Generate TypeScript definitions from English messages
export type Messages = MessageNamespace;
// Message loading with caching
const messageCache = new Map<string, any>();
export async function loadMessages(locale: string): Promise<Messages> {
if (messageCache.has(locale)) {
return messageCache.get(locale);
}
try {
const [common, auth, dashboard, products, billing] = await Promise.all([
import(`../messages/${locale}/common.json`),
import(`../messages/${locale}/auth.json`),
import(`../messages/${locale}/dashboard.json`),
import(`../messages/${locale}/products.json`),
import(`../messages/${locale}/billing.json`)
]);
const messages = {
common: common.default,
auth: auth.default,
dashboard: dashboard.default,
products: products.default,
billing: billing.default
};
messageCache.set(locale, messages);
return messages;
} catch (error) {
console.error(`Failed to load messages for locale ${locale}:`, error);
// Fallback to English
return loadMessages('en-US');
}
}
Advanced Translation Patterns
Here's how I handle complex translation scenarios:
// components/TranslatedContent.tsx
import { useTranslations, useFormatter } from 'next-intl';
interface ProductCardProps {
product: {
name: string;
price: number;
stock: number;
createdAt: Date;
description?: string;
};
locale: string;
}
export function ProductCard({ product, locale }: ProductCardProps) {
const t = useTranslations('products');
const format = useFormatter();
// Complex pluralization with context
const stockMessage = t('stock', {
count: product.stock,
// ICU select for different stock levels
level: product.stock === 0 ? 'out' : product.stock < 10 ? 'low' : 'good'
});
// Rich text formatting with components
const descriptionWithFormatting = t.rich('description', {
product: product.name,
bold: (chunks) => <strong>{chunks}</strong>,
link: (chunks) => <a href="/learn-more">{chunks}</a>
});
return (
<div className={`product-card ${locale.includes('ar') ? 'rtl' : 'ltr'}`}>
<h3>{product.name}</h3>
{/* Localized price with currency */}
<div className="price">
{format.number(product.price, {
style: 'currency',
currency: getCurrencyForLocale(locale)
})}
</div>
{/* Localized date */}
<div className="date">
{t('createdOn', {
date: format.dateTime(product.createdAt, 'long')
})}
</div>
{/* Complex pluralization */}
<div className="stock">{stockMessage}</div>
{/* Rich text */}
{product.description && (
<div className="description">
{descriptionWithFormatting}
</div>
)}
</div>
);
}
The corresponding message files:
// messages/en-US/products.json
{
"stock": "{count, plural, =0 {{level, select, out {Out of stock} other {No items}}} one {{level, select, low {Only 1 left - hurry!} other {1 in stock}}} other {{level, select, low {Only {count} left!} other {{count} in stock}}}}",
"createdOn": "Added on {date}",
"description": "Learn more about <bold>{product}</bold> by visiting our <link>detailed guide</link>."
}
// messages/es-ES/products.json
{
"stock": "{count, plural, =0 {{level, select, out {Agotado} other {Sin artículos}}} one {{level, select, low {¡Solo queda 1!} other {1 en stock}}} other {{level, select, low {¡Solo quedan {count}!} other {{count} en stock}}}}",
"createdOn": "Agregado el {date}",
"description": "Aprende más sobre <bold>{product}</bold> visitando nuestra <link>guía detallada</link>."
}
RTL Language Support
Supporting RTL languages requires more than just text direction:
// components/RTLProvider.tsx
import { useLocale } from 'next-intl';
import { useEffect } from 'react';
const RTL_LOCALES = ['ar-SA', 'he-IL', 'fa-IR'];
export function RTLProvider({ children }: { children: React.ReactNode }) {
const locale = useLocale();
const isRTL = RTL_LOCALES.includes(locale);
useEffect(() => {
// Update document direction
document.documentElement.dir = isRTL ? 'rtl' : 'ltr';
document.documentElement.lang = locale;
// Update CSS custom properties for RTL-aware styling
document.documentElement.style.setProperty('--dir-factor', isRTL ? '-1' : '1');
document.documentElement.style.setProperty('--start', isRTL ? 'right' : 'left');
document.documentElement.style.setProperty('--end', isRTL ? 'left' : 'right');
}, [locale, isRTL]);
return (
<div className={`app ${isRTL ? 'rtl' : 'ltr'}`}>
{children}
</div>
);
}
/* styles/rtl.css - RTL-aware CSS */
.sidebar {
/* Use logical properties */
margin-inline-start: 1rem;
border-inline-end: 1px solid #ccc;
/* Use CSS custom properties for complex layouts */
transform: translateX(calc(100px * var(--dir-factor)));
}
.navigation {
/* Flip icons for RTL */
.icon-arrow {
transform: scaleX(var(--dir-factor));
}
}
/* RTL-specific overrides */
.rtl {
.dropdown-menu {
left: auto;
right: 0;
}
.tooltip {
text-align: right;
}
/* Adjust spacing for Arabic fonts */
&[lang="ar"] {
line-height: 1.8;
font-size: 1.1em;
}
}
SEO Optimization for Multilingual Sites
Proper SEO requires careful attention to URL structure and metadata:
// app/[locale]/layout.tsx
import { useLocale, useTranslations } from 'next-intl';
import { routing } from '@/i18n/routing';
interface LayoutProps {
children: React.ReactNode;
params: { locale: string };
}
export async function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export async function generateMetadata({ params }: { params: { locale: string } }) {
const locale = params.locale;
const t = await getTranslations({ locale, namespace: 'meta' });
const canonicalUrl = `https://example.com/${locale}`;
const alternateUrls = routing.locales.reduce((acc, loc) => {
acc[loc] = `https://example.com/${loc}`;
return acc;
}, {} as Record<string, string>);
return {
title: t('title'),
description: t('description'),
canonical: canonicalUrl,
alternates: {
canonical: canonicalUrl,
languages: alternateUrls
},
other: {
'og:locale': locale,
'og:locale:alternate': routing.locales.filter(l => l !== locale)
}
};
}
export default function LocaleLayout({ children, params }: LayoutProps) {
const locale = useLocale();
return (
<html lang={locale} dir={locale.includes('ar') ? 'rtl' : 'ltr'}>
<head>
{/* Hreflang links for SEO */}
{routing.locales.map((lng) => (
<link
key={lng}
rel="alternate"
hrefLang={lng}
href={`https://example.com/${lng}`}
/>
))}
<link
rel="alternate"
hrefLang="x-default"
href="https://example.com/en-US"
/>
</head>
<body>
<RTLProvider>
<LocaleProvider locale={locale}>
{children}
</LocaleProvider>
</RTLProvider>
</body>
</html>
);
}
Automated Translation Workflow
I integrated Crowdin for automated translation management:
// scripts/sync-translations.ts
import { crowdin, sourceFilesApi } from '@crowdin/crowdin-api-client';
interface TranslationConfig {
projectId: string;
token: string;
sourceDir: string;
outputDir: string;
}
class TranslationManager {
private crowdinApi: sourceFilesApi;
constructor(private config: TranslationConfig) {
this.crowdinApi = new crowdin({
token: config.token,
projectId: config.projectId
}).sourceFilesApi;
}
async uploadSourceFiles(): Promise<void> {
const sourceFiles = await glob(`${this.config.sourceDir}/**/*.json`);
for (const file of sourceFiles) {
const relativePath = path.relative(this.config.sourceDir, file);
const content = await fs.readFile(file, 'utf8');
try {
// Check if file exists in Crowdin
const existingFiles = await this.crowdinApi.listProjectFiles(
this.config.projectId
);
const existingFile = existingFiles.data.find(
f => f.data.path === `/${relativePath}`
);
if (existingFile) {
// Update existing file
await this.crowdinApi.updateFile(
this.config.projectId,
existingFile.data.id,
{
content
}
);
console.log(`✓ Updated ${relativePath}`);
} else {
// Create new file
await this.crowdinApi.createFile(
this.config.projectId,
{
name: path.basename(file),
path: `/${relativePath}`,
content
}
);
console.log(`✓ Created ${relativePath}`);
}
} catch (error) {
console.error(`✗ Failed to sync ${relativePath}:`, error);
}
}
}
async downloadTranslations(): Promise<void> {
// Build all translations
const buildResponse = await this.crowdinApi.buildProject(
this.config.projectId
);
// Wait for build to complete
await this.waitForBuild(buildResponse.data.id);
// Download translations
const downloadResponse = await this.crowdinApi.downloadTranslations(
this.config.projectId
);
// Extract and save files
const zip = new StreamZip.async({ file: downloadResponse.data.url });
await zip.extract(null, this.config.outputDir);
await zip.close();
console.log('✓ Downloaded all translations');
}
private async waitForBuild(buildId: number): Promise<void> {
let status = 'inProgress';
while (status === 'inProgress') {
await new Promise(resolve => setTimeout(resolve, 2000));
const buildStatus = await this.crowdinApi.checkBuildStatus(
this.config.projectId,
buildId
);
status = buildStatus.data.status;
}
if (status !== 'finished') {
throw new Error(`Build failed with status: ${status}`);
}
}
}
// Usage in CI/CD
async function main() {
const manager = new TranslationManager({
projectId: process.env.CROWDIN_PROJECT_ID!,
token: process.env.CROWDIN_TOKEN!,
sourceDir: './messages/en-US',
outputDir: './messages'
});
if (process.argv.includes('--upload')) {
await manager.uploadSourceFiles();
}
if (process.argv.includes('--download')) {
await manager.downloadTranslations();
}
}
main().catch(console.error);
Performance Monitoring for i18n
I track performance metrics specific to internationalization:
// lib/i18n-analytics.ts
interface I18nMetrics {
locale: string;
messageLoadTime: number;
totalTranslations: number;
missingTranslations: string[];
renderTime: number;
bundleSize: number;
}
export class I18nAnalytics {
private metrics: Map<string, I18nMetrics> = new Map();
startMessageLoad(locale: string): () => void {
const start = performance.now();
return () => {
const loadTime = performance.now() - start;
this.updateMetric(locale, 'messageLoadTime', loadTime);
};
}
trackMissingTranslation(locale: string, key: string): void {
const metric = this.metrics.get(locale);
if (metric) {
metric.missingTranslations.push(key);
}
// Report to analytics service
analytics.track('missing_translation', {
locale,
key,
url: window.location.href
});
}
measureBundleSize(locale: string, size: number): void {
this.updateMetric(locale, 'bundleSize', size);
}
getReport(locale: string): I18nMetrics | null {
return this.metrics.get(locale) || null;
}
private updateMetric(
locale: string,
key: keyof I18nMetrics,
value: any
): void {
if (!this.metrics.has(locale)) {
this.metrics.set(locale, {
locale,
messageLoadTime: 0,
totalTranslations: 0,
missingTranslations: [],
renderTime: 0,
bundleSize: 0
});
}
const metric = this.metrics.get(locale)!;
(metric as any)[key] = value;
}
}
export const i18nAnalytics = new I18nAnalytics();
After implementing this comprehensive i18n system, our platform successfully serves users in 12 languages with 99.9% translation coverage. The automated workflow reduced translation management time by 80%, and the performance optimizations kept our international pages loading under 2 seconds globally.
The key lesson: treat i18n as a first-class architectural concern, not an afterthought. Proper planning for localization, automated workflows, and performance monitoring will save countless hours and create a truly global user experience.