Published on

Implement i18n Configuration for Next.js Project

What is I18n

Internationalization (i18n) is the process of designing and developing a software application so that it can be adapted to various languages and regions without requiring engineering changes to the source code. In the context of web development, i18n allows applications to support multiple languages, making them accessible to a broader audience.

Setting up Next.js for I18n

Prerequisites

  • Next.js => React framework for production.
  • Next-intl => support for internationalization in Next.js applications.
  • I18n Ally (lokalise.i18n-ally)

Install Next-intl

pnpm install next-intl

src/
├── app/
│   └── [locale]/
│       ├── layout.tsx
│       └── page.tsx
├── i18n/
│   ├── routing.ts
│   ├── request.ts
│   └── navigation.ts
├── messages/
│   ├── en.json
│   └── vi.json
├── components/
│   └── LanguageSwitcher.tsx
└── middleware.ts

Create Translation Files

messages/en.json

{
  "common": {
    "welcome": "Welcome",
    "description": "This is a multilingual Next.js application",
    "language": "Language",
    "switchLanguage": "Switch Language"
  },
  "nav": {
    "home": "Home",
    "about": "About",
    "contact": "Contact"
  },
  "home": {
    "title": "Home Page",
    "greeting": "Hello, {name}!"
  }
}

messages/vi.json

{
  "common": {
    "welcome": "Chào mừng",
    "description": "Đây là ứng dụng Next.js đa ngôn ngữ",
    "language": "Ngôn ngữ",
    "switchLanguage": "Chuyển ngôn ngữ"
  },
  "nav": {
    "home": "Trang chủ",
    "about": "Giới thiệu",
    "contact": "Liên hệ"
  },
  "home": {
    "title": "Trang Chủ",
    "greeting": "Xin chào, {name}!"
  }
}

Configure routing

src/i18n/routing.ts

import { defineRouting } from 'next-intl/routing'

export const routing = defineRouting({
  locales: ['en', 'vi'],
  defaultLocale: 'en', // English as default
  localePrefix: 'as-needed', // Only show prefix for non-default locales
})

export type Locale = (typeof routing.locales)[number]

src/i18n/navigation.ts

import { createNavigation } from 'next-intl/navigation'
import { routing } from './routing'

export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing)

src/i18n/request.ts

import { getRequestConfig } from 'next-intl/server'
import { routing } from './routing'

export default getRequestConfig(async ({ requestLocale }) => {
  let locale = await requestLocale

  // Validate that the incoming locale is valid
  if (!locale || !routing.locales.includes(locale as any)) {
    locale = routing.defaultLocale
  }

  return {
    locale,
    messages: (await import(`../../messages/${locale}.json`)).default,
  }
})

Setup middleware

src/middleware.ts

import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'

export default createMiddleware(routing)

export const config = {
  // Match all pathnames except for
  // - API routes
  // - Static files
  // - Internal Next.js paths
  matcher: [
    '/((?!api|_next|_vercel|.*\\..*).*)',
    // Also match root
    '/',
  ],
}

Update Next.js Config

next.config.ts

import type { NextConfig } from 'next'
import createNextIntlPlugin from 'next-intl/plugin'

const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')

const nextConfig: NextConfig = {
  // Your other config options
}

export default withNextIntl(nextConfig)

Setup Default Route to English

The configuration above already sets English as default with localePrefix: 'as-needed':

/ → English (no prefix needed) /vi → Vietnamese /about → English about page /vi/about → Vietnamese about page

App Layout with Locale src/app/[locale]/layout.tsx

import { NextIntlClientProvider } from 'next-intl';
import { getMessages, getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing';

type Props = {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
};

export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}

export async function generateMetadata({ params }: Props) {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'common' });

  return {
    title: t('welcome'),
    description: t('description')
  };
}

export default async function LocaleLayout({ children, params }: Props) {
  const { locale } = await params;

  // Validate locale
  if (!routing.locales.includes(locale as any)) {
    notFound();
  }

  // Get messages for client components
  const messages = await getMessages();

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

src/app/[locale]/page.tsx

import { useTranslations } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import LanguageSwitcher from '@/components/LanguageSwitcher';

export default function HomePage() {
  const t = useTranslations('home');
  const tCommon = useTranslations('common');

  return (
    <main className="min-h-screen p-8">
      <div className="max-w-4xl mx-auto">
        <div className="flex justify-between items-center mb-8">
          <h1 className="text-3xl font-bold">{t('title')}</h1>
          <LanguageSwitcher />
        </div>

        <p className="text-lg mb-4">{tCommon('welcome')}</p>
        <p className="text-gray-600">{t('greeting', { name: 'zzz' })}</p>
      </div>
    </main>
  );
}

Language Switcher Component

src/components/LanguageSwitcher.tsx

'use client';

import { useLocale, useTranslations } from 'next-intl';
import { useRouter, usePathname } from '@/i18n/navigation';
import { routing, type Locale } from '@/i18n/routing';
import { useTransition } from 'react';

const languageNames: Record<Locale, string> = {
  en: 'English',
  vi: 'Tiếng Việt',
};

const languageFlags: Record<Locale, string> = {
  en: '🇺🇸',
  vi: '🇻🇳',
};

export default function LanguageSwitcher() {
  const t = useTranslations('common');
  const locale = useLocale() as Locale;
  const router = useRouter();
  const pathname = usePathname();
  const [isPending, startTransition] = useTransition();

  const handleLocaleChange = (newLocale: Locale) => {
    startTransition(() => {
      router.replace(pathname, { locale: newLocale });
    });
  };

  return (
    <div className="relative inline-block">
      <label className="sr-only">{t('switchLanguage')}</label>
      <select
        value={locale}
        onChange={(e) => handleLocaleChange(e.target.value as Locale)}
        disabled={isPending}
        className="appearance-none bg-white border border-gray-300 rounded-lg px-4 py-2 pr-8 cursor-pointer hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
      >
        {routing.locales.map((loc) => (
          <option key={loc} value={loc}>
            {languageFlags[loc]} {languageNames[loc]}
          </option>
        ))}
      </select>
      {isPending && (
        <span className="absolute right-2 top-1/2 -translate-y-1/2">
          <svg
            className="animate-spin h-4 w-4 text-gray-500"
            viewBox="0 0 24 24"
          >
            <circle
              className="opacity-25"
              cx="12"
              cy="12"
              r="10"
              stroke="currentColor"
              strokeWidth="4"
              fill="none"
            />
            <path
              className="opacity-75"
              fill="currentColor"
              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
            />
          </svg>
        </span>
      )}
    </div>
  );
}

Hook custom

src/hooks/useLanguage.ts

'use client'

import { useLocale } from 'next-intl'
import { useRouter, usePathname } from '@/i18n/navigation'
import { routing, type Locale } from '@/i18n/routing'
import { useTransition, useCallback } from 'react'

export function useLanguage() {
  const locale = useLocale() as Locale
  const router = useRouter()
  const pathname = usePathname()
  const [isPending, startTransition] = useTransition()

  const setLocale = useCallback(
    (newLocale: Locale) => {
      if (newLocale === locale) return
      if (!routing.locales.includes(newLocale)) {
        console.warn(`Invalid locale: ${newLocale}`)
        return
      }
      startTransition(() => {
        router.replace(pathname, { locale: newLocale })
      })
    },
    [locale, pathname, router]
  )

  const toggleLocale = useCallback(() => {
    const currentIndex = routing.locales.indexOf(locale)
    const nextIndex = (currentIndex + 1) % routing.locales.length
    setLocale(routing.locales[nextIndex])
  }, [locale, setLocale])

  return {
    locale,
    locales: routing.locales,
    defaultLocale: routing.defaultLocale,
    setLocale,
    toggleLocale,
    isPending,
  }
}

Conclusion

By following this guide, you have successfully implemented internationalization (i18n) in your Next.js project using the next-intl library. Your application can now support multiple languages, allowing users to switch between them seamlessly. This setup enhances the user experience and broadens the reach of your application to a global audience.