Internationalization (i18n) in Next.js
Understanding Internationalization in Next.js
Internationalization (i18n) is the process of preparing your application to support multiple languages and regions. Next.js provides powerful built-in features for creating multilingual applications with locale routing, automatic language detection, and seamless translations.
Next.js i18n Routing
Next.js supports two routing strategies for internationalization:
- Sub-path Routing: /en/about, /ar/about
- Domain Routing: example.com, example.ar
Setting Up i18n with App Router
First, create a folder structure for your locales:
app/
├── [locale]/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── about/
│ │ └── page.tsx
│ └── products/
│ └── page.tsx
└── middleware.ts
Creating i18n Configuration
Create a configuration file for your supported locales:
// i18n.config.ts
export const i18n = {
defaultLocale: 'en',
locales: ['en', 'ar', 'fr', 'es'],
} as const;
export type Locale = (typeof i18n)['locales'][number];
// Locale names for display
export const localeNames: Record<Locale, string> = {
en: 'English',
ar: 'العربية',
fr: 'Français',
es: 'Español',
};
// RTL languages
export const rtlLocales: Locale[] = ['ar'];
Middleware for Locale Detection
Create middleware to handle locale detection and redirects:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { i18n } from './i18n.config';
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// Check if locale is in pathname
const pathnameIsMissingLocale = i18n.locales.every(
(locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
);
// Redirect if no locale
if (pathnameIsMissingLocale) {
const locale = getLocale(request);
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
);
}
}
function getLocale(request: NextRequest): string {
// 1. Check cookie
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
if (cookieLocale && i18n.locales.includes(cookieLocale as any)) {
return cookieLocale;
}
// 2. Check Accept-Language header
const acceptLanguage = request.headers.get('accept-language');
if (acceptLanguage) {
const preferredLocale = acceptLanguage
.split(',')
.map((lang) => lang.split(';')[0].trim().split('-')[0])
.find((lang) => i18n.locales.includes(lang as any));
if (preferredLocale) {
return preferredLocale;
}
}
// 3. Default locale
return i18n.defaultLocale;
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
Root Layout with Locale
Create a dynamic layout that handles locale-specific settings:
// app/[locale]/layout.tsx
import { Inter } from 'next/font/google';
import { Locale, i18n, rtlLocales } from '@/i18n.config';
import { notFound } from 'next/navigation';
const inter = Inter({ subsets: ['latin'] });
export async function generateStaticParams() {
return i18n.locales.map((locale) => ({ locale }));
}
export default function LocaleLayout({
children,
params: { locale },
}: {
children: React.ReactNode;
params: { locale: Locale };
}) {
// Validate locale
if (!i18n.locales.includes(locale)) {
notFound();
}
const isRTL = rtlLocales.includes(locale);
return (
<html lang={locale} dir={isRTL ? 'rtl' : 'ltr'}>
<body className={inter.className}>
{children}
</body>
</html>
);
}
Creating Translation Dictionaries
Organize your translations in separate files:
// dictionaries/en.json
{
"navigation": {
"home": "Home",
"about": "About",
"products": "Products",
"contact": "Contact"
},
"home": {
"title": "Welcome to Our Website",
"description": "Discover amazing products and services",
"cta": "Get Started"
},
"products": {
"title": "Our Products",
"featured": "Featured Products",
"viewDetails": "View Details",
"addToCart": "Add to Cart"
},
"common": {
"loading": "Loading...",
"error": "Something went wrong",
"retry": "Try Again",
"learnMore": "Learn More"
}
}
// dictionaries/ar.json
{
"navigation": {
"home": "الرئيسية",
"about": "من نحن",
"products": "المنتجات",
"contact": "اتصل بنا"
},
"home": {
"title": "مرحباً بك في موقعنا",
"description": "اكتشف منتجات وخدمات مذهلة",
"cta": "ابدأ الآن"
},
"products": {
"title": "منتجاتنا",
"featured": "المنتجات المميزة",
"viewDetails": "عرض التفاصيل",
"addToCart": "أضف إلى السلة"
},
"common": {
"loading": "جاري التحميل...",
"error": "حدث خطأ ما",
"retry": "حاول مرة أخرى",
"learnMore": "اعرف المزيد"
}
}
Dictionary Loader Function
Create a function to load translations:
// lib/dictionaries.ts
import 'server-only';
import type { Locale } from '@/i18n.config';
const dictionaries = {
en: () => import('@/dictionaries/en.json').then((module) => module.default),
ar: () => import('@/dictionaries/ar.json').then((module) => module.default),
fr: () => import('@/dictionaries/fr.json').then((module) => module.default),
es: () => import('@/dictionaries/es.json').then((module) => module.default),
};
export const getDictionary = async (locale: Locale) => {
return dictionaries[locale]();
};
Using Translations in Server Components
Access translations in your Server Components:
// app/[locale]/page.tsx
import { getDictionary } from '@/lib/dictionaries';
import { Locale } from '@/i18n.config';
export default async function HomePage({ params }: { params: { locale: Locale } }) {
const dict = await getDictionary(params.locale);
return (
<main>
<h1>{dict.home.title}</h1>
<p>{dict.home.description}</p>
<button>{dict.home.cta}</button>
</main>
);
}
Language Switcher Component
Create a component to switch between languages:
// components/LanguageSwitcher.tsx
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { i18n, localeNames, type Locale } from '@/i18n.config';
import { useState } from 'react';
export default function LanguageSwitcher({ currentLocale }: { currentLocale: Locale }) {
const pathname = usePathname();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const switchLocale = (locale: Locale) => {
// Remove current locale from pathname
const segments = pathname.split('/');
segments[1] = locale;
const newPath = segments.join('/');
// Set cookie for locale preference
document.cookie = `NEXT_LOCALE=${locale}; path=/; max-age=31536000`;
router.push(newPath);
setIsOpen(false);
};
return (
<div className="language-switcher">
<button onClick={() => setIsOpen(!isOpen)}>
{localeNames[currentLocale]}
</button>
{isOpen && (
<ul>
{i18n.locales.map((locale) => (
<li key={locale}>
<button
onClick={() => switchLocale(locale)}
className={locale === currentLocale ? 'active' : ''}
>
{localeNames[locale]}
</button>
</li>
))}
</ul>
)}
</div>
);
}
RTL Support
Add CSS for Right-to-Left language support:
/* styles/rtl.css */
/* General RTL adjustments */
html[dir='rtl'] {
text-align: right;
}
html[dir='rtl'] .container {
direction: rtl;
}
/* Navigation */
html[dir='rtl'] .nav-list {
flex-direction: row-reverse;
}
/* Text alignment */
html[dir='rtl'] p,
html[dir='rtl'] h1,
html[dir='rtl'] h2,
html[dir='rtl'] h3 {
text-align: right;
}
/* Margins and paddings */
html[dir='rtl'] .ml-4 {
margin-left: 0;
margin-right: 1rem;
}
html[dir='rtl'] .mr-4 {
margin-right: 0;
margin-left: 1rem;
}
/* Logical properties (better approach) */
.spacing {
margin-inline-start: 1rem;
padding-inline-end: 2rem;
}
Locale-Aware Links
Create a wrapper for Next.js Link component that preserves locale:
// components/LocaleLink.tsx
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Locale } from '@/i18n.config';
interface LocaleLinkProps {
href: string;
children: React.ReactNode;
className?: string;
}
export default function LocaleLink({ href, children, className }: LocaleLinkProps) {
const params = useParams();
const locale = params?.locale as Locale;
// Prepend locale to href if not present
const localizedHref = href.startsWith('/')
? `/${locale}${href}`
: `/${locale}/${href}`;
return (
<Link href={localizedHref} className={className}>
{children}
</Link>
);
}
Translating Dynamic Content
Handle translations for content from APIs or databases:
// lib/translations.ts
import { Locale } from '@/i18n.config';
interface TranslatableContent {
title: {
en: string;
ar: string;
fr: string;
es: string;
};
description: {
en: string;
ar: string;
fr: string;
es: string;
};
}
export function getTranslatedContent<T extends TranslatableContent>(
content: T,
locale: Locale
): { title: string; description: string } {
return {
title: content.title[locale],
description: content.description[locale],
};
}
// Usage in component
const product = {
id: 1,
title: {
en: 'Laptop',
ar: 'حاسوب محمول',
fr: 'Ordinateur portable',
es: 'Portátil',
},
description: {
en: 'High-performance laptop',
ar: 'حاسوب محمول عالي الأداء',
fr: 'Ordinateur portable haute performance',
es: 'Portátil de alto rendimiento',
},
};
const translated = getTranslatedContent(product, 'ar');
// { title: 'حاسوب محمول', description: 'حاسوب محمول عالي الأداء' }
Number and Date Formatting
Use the Intl API for locale-aware formatting:
// lib/formatting.ts
import { Locale } from '@/i18n.config';
export function formatNumber(value: number, locale: Locale): string {
return new Intl.NumberFormat(locale).format(value);
}
export function formatCurrency(value: number, locale: Locale, currency = 'USD'): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(value);
}
export function formatDate(date: Date, locale: Locale): string {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date);
}
export function formatRelativeTime(date: Date, locale: Locale): string {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
const diffInDays = Math.floor((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
return rtf.format(diffInDays, 'day');
}
// Usage
formatNumber(1234567.89, 'ar'); // "١٬٢٣٤٬٥٦٧٫٨٩"
formatCurrency(99.99, 'en', 'USD'); // "$99.99"
formatDate(new Date(), 'ar'); // "١٤ فبراير ٢٠٢٦"
formatRelativeTime(new Date(Date.now() - 86400000), 'en'); // "yesterday"
SEO with hreflang
Add alternate language links for SEO:
// app/[locale]/layout.tsx
import { Metadata } from 'next';
import { i18n, Locale } from '@/i18n.config';
export async function generateMetadata({
params,
}: {
params: { locale: Locale };
}): Promise<Metadata> {
const baseUrl = 'https://example.com';
return {
alternates: {
canonical: `${baseUrl}/${params.locale}`,
languages: Object.fromEntries(
i18n.locales.map((locale) => [locale, `${baseUrl}/${locale}`])
),
},
};
}
Translation Helper Hook
Create a client-side hook for translations:
// hooks/useTranslations.ts
'use client';
import { useParams } from 'next/navigation';
import { Locale } from '@/i18n.config';
import { createContext, useContext, ReactNode } from 'react';
type Dictionary = Record<string, any>;
const TranslationsContext = createContext<Dictionary | null>(null);
export function TranslationsProvider({
children,
dictionary,
}: {
children: ReactNode;
dictionary: Dictionary;
}) {
return (
<TranslationsContext.Provider value={dictionary}>
{children}
</TranslationsContext.Provider>
);
}
export function useTranslations(namespace?: string) {
const dictionary = useContext(TranslationsContext);
const params = useParams();
const locale = params?.locale as Locale;
if (!dictionary) {
throw new Error('useTranslations must be used within TranslationsProvider');
}
const t = (key: string, variables?: Record<string, string>): string => {
const keys = namespace ? `${namespace}.${key}`.split('.') : key.split('.');
let value: any = dictionary;
for (const k of keys) {
value = value?.[k];
}
if (typeof value !== 'string') {
console.warn(`Translation missing for key: ${key}`);
return key;
}
// Replace variables
if (variables) {
return Object.entries(variables).reduce(
(str, [varKey, varValue]) => str.replace(`{{${varKey}}}`, varValue),
value
);
}
return value;
};
return { t, locale };
}
// Usage in client component
function MyComponent() {
const { t } = useTranslations('products');
return <h1>{t('title')}</h1>;
}
Practice Exercise
Task: Build a multilingual e-commerce product page:
- Support English, Arabic, and Spanish languages
- Implement proper RTL support for Arabic
- Create a language switcher in the navigation
- Format prices according to locale (USD for en, SAR for ar, EUR for es)
- Format product dates in locale-specific format
- Add hreflang tags for SEO
- Translate product names and descriptions from API data
Bonus: Implement a translation fallback system that displays English if a translation is missing for the current locale.
Summary
Key takeaways about internationalization in Next.js:
- Use middleware for automatic locale detection and routing
- Organize translations in JSON dictionary files per locale
- Support RTL languages with proper dir and CSS adjustments
- Use Intl API for number, currency, and date formatting
- Implement locale-aware links and navigation
- Add hreflang tags for multilingual SEO
- Separate server-side (getDictionary) and client-side (useTranslations) translation approaches