Next.js

Internationalization (i18n) in Next.js

40 min Lesson 22 of 40

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
Important: In the App Router, i18n routing is handled manually through middleware and folder structure, giving you more flexibility and control.

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]();
};
Tip: The 'server-only' package ensures that dictionary loading only happens on the server, preventing translation files from being bundled in the client JavaScript.

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;
}
Modern Approach: Use CSS logical properties (margin-inline-start, padding-inline-end) instead of directional properties (margin-left, padding-right) for automatic RTL support.

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>;
}
Warning: Avoid mixing server and client translation approaches. Use getDictionary() in Server Components and useTranslations() in Client Components consistently.

Practice Exercise

Task: Build a multilingual e-commerce product page:

  1. Support English, Arabic, and Spanish languages
  2. Implement proper RTL support for Arabic
  3. Create a language switcher in the navigation
  4. Format prices according to locale (USD for en, SAR for ar, EUR for es)
  5. Format product dates in locale-specific format
  6. Add hreflang tags for SEO
  7. 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