JB logo

Command Palette

Search for a command to run...

yOUTUBE
Blog
Next

Complete Guide to Internationalization in Next.js - The Developer's Handbook

Master internationalization in Next.js with next-intl package, routing-based i18n, translated form validation, and bilingual English/Spanish implementation for better global reach.

Next.js Internationalization Guide with next-intl

Table of Contents

  1. Overview
  2. Why Choose Routing-based i18n
  3. Installation and Setup
  4. Project Structure
  5. Configuration Files
  6. Page Implementation
  7. Translation Management
  8. Advanced Features
  9. Best Practices
  10. Database Content Recommendations

Overview

This guide covers implementing internationalization (i18n) in Next.js 15 using the next-intl package. We'll build a multilingual application with proper SEO optimization, dynamic routing, and form validation translation.

Features We'll Implement:

  • Multi-language support (English, French, Dutch)
  • SEO-optimized URLs for each language
  • Translated form validation messages
  • Language switcher component
  • Server and client component support

Why Choose Routing-based i18n

  • ✅ Simpler implementation
  • ✅ No middleware required
  • ❌ Same URL for all languages (bad for SEO)
  • ❌ Not Google-friendly
  • ❌ Difficult to share language-specific links

Routing-based (With Routing)

  • ✅ SEO-optimized with unique URLs per language
  • ✅ Better search engine indexing
  • ✅ Shareable language-specific links
  • ✅ Professional implementation
  • ⚠️ Requires middleware setup

Recommendation: Always use routing-based i18n for production applications.

Installation and Setup

Step 1: Install Dependencies

pnpm add next-intl
# or
yarn add next-intl
# or
pnpm add next-intl

Step 2: Project Structure

my-app/
├── app/
│   └── [locale]/
│       ├── layout.tsx
│       ├── page.tsx
│       ├── about/
│       │   └── page.tsx
│       ├── products/
│       │   └── page.tsx
│       └── contact/
│           └── page.tsx
├── components/
│   ├── Navbar.tsx
│   ├── LocaleSwitcher.tsx
│   └── ContactForm.tsx
├── i18n/
│   ├── routing.ts
│   └── request.ts
├── messages/
│   ├── en.json
│   ├── fr.json
│   └── nl.json
├── lib/
│   └── validations.ts
├── middleware.ts
└── next.config.ts

Configuration Files

next.config.ts

import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
 
const withNextIntl = createNextIntlPlugin();
 
const nextConfig: NextConfig = {
  // Your existing Next.js config
};
 
export default withNextIntl(nextConfig);

i18n/routing.ts

import { defineRouting } from "next-intl/routing";
 
export const routing = defineRouting({
  // A list of all locales that are supported
  locales: ["en", "fr", "nl"],
 
  // Used when no locale matches
  defaultLocale: "en",
 
  // Custom pathnames for different locales
  pathnames: {
    "/": "/",
    "/about": {
      en: "/about",
      fr: "/a-propos",
      nl: "/over-ons",
    },
    "/contact": {
      en: "/contact-me",
      fr: "/contact-moi",
      nl: "/contact",
    },
    "/products": {
      en: "/products",
      fr: "/produits",
      nl: "/producten",
    },
  },
});
 
export type Locale = (typeof routing.locales)[number];

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 import(`../messages/${locale}.json`)).default,
  };
});

middleware.ts

import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
 
export default createMiddleware(routing);
 
export const config = {
  // Match only internationalized pathnames
  matcher: ["/", "/(fr|nl|en)/:path*"],
};

Translation Files

messages/en.json

{
  "Navigation": {
    "home": "Home",
    "about": "About",
    "products": "Products",
    "contact": "Contact"
  },
  "HomePage": {
    "title": "Welcome",
    "description": "Welcome to our homepage",
    "welcome": {
      "title": "Welcome to the homepage"
    }
  },
  "AboutPage": {
    "title": "About Us",
    "description": "Learn more about our company",
    "content": "We are a leading company in our field with years of experience."
  },
  "ProductsPage": {
    "title": "Our Products",
    "description": "Explore our amazing products",
    "loading": "Loading products...",
    "price": "Price",
    "addToCart": "Add to Cart"
  },
  "ContactForm": {
    "title": "Contact Us",
    "name": "Name",
    "email": "Email",
    "message": "Message",
    "submit": "Submit"
  },
  "Validation": {
    "nameRequired": "Name is required",
    "nameMin": "Name must be at least 2 characters",
    "emailInvalid": "Invalid email address",
    "messageRequired": "Message is required",
    "messageMin": "Message must be at least 10 characters"
  }
}

messages/fr.json

{
  "Navigation": {
    "home": "Accueil",
    "about": "À propos",
    "products": "Produits",
    "contact": "Contact"
  },
  "HomePage": {
    "title": "Bienvenue",
    "description": "Bienvenue sur notre page d'accueil",
    "welcome": {
      "title": "Bienvenue sur la page d'accueil"
    }
  },
  "AboutPage": {
    "title": "À propos de nous",
    "description": "Découvrez notre entreprise",
    "content": "Nous sommes une entreprise leader dans notre domaine avec des années d'expérience."
  },
  "ProductsPage": {
    "title": "Nos Produits",
    "description": "Explorez nos produits extraordinaires",
    "loading": "Chargement des produits...",
    "price": "Prix",
    "addToCart": "Ajouter au panier"
  },
  "ContactForm": {
    "title": "Contactez-nous",
    "name": "Nom",
    "email": "Email",
    "message": "Message",
    "submit": "Envoyer"
  },
  "Validation": {
    "nameRequired": "Le nom est requis",
    "nameMin": "Le nom doit contenir au moins 2 caractères",
    "emailInvalid": "Adresse email invalide",
    "messageRequired": "Le message est requis",
    "messageMin": "Le message doit contenir au moins 10 caractères"
  }
}

messages/nl.json

{
  "Navigation": {
    "home": "Home",
    "about": "Over ons",
    "products": "Producten",
    "contact": "Contact"
  },
  "HomePage": {
    "title": "Welkom",
    "description": "Welkom op onze homepage",
    "welcome": {
      "title": "Welkom op de homepage"
    }
  },
  "AboutPage": {
    "title": "Over Ons",
    "description": "Meer over ons bedrijf",
    "content": "Wij zijn een toonaangevend bedrijf in ons vakgebied met jaren ervaring."
  },
  "ProductsPage": {
    "title": "Onze Producten",
    "description": "Ontdek onze geweldige producten",
    "loading": "Producten laden...",
    "price": "Prijs",
    "addToCart": "Toevoegen aan winkelwagen"
  },
  "ContactForm": {
    "title": "Contact Ons",
    "name": "Naam",
    "email": "Email",
    "message": "Bericht",
    "submit": "Versturen"
  },
  "Validation": {
    "nameRequired": "Naam is verplicht",
    "nameMin": "Naam moet minimaal 2 karakters bevatten",
    "emailInvalid": "Ongeldig emailadres",
    "messageRequired": "Bericht is verplicht",
    "messageMin": "Bericht moet minimaal 10 karakters bevatten"
  }
}

Page Implementation

app/[locale]/layout.tsx

import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing';
import Navbar from '@/components/Navbar';
import './globals.css';
 
type Props = {
  children: React.ReactNode;
  params: { locale: string };
};
 
export default async function LocaleLayout({
  children,
  params: { locale }
}: Props) {
  // Ensure that the incoming `locale` is valid
  if (!routing.locales.includes(locale as any)) {
    notFound();
  }
 
  // Providing all messages to the client
  // side is the easiest way to get started
  const messages = await getMessages();
 
  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages}>
          <Navbar />
          <main className="container mx-auto px-4 py-8">
            {children}
          </main>
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

app/[locale]/page.tsx (Home Page)

import { useTranslations } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import Link from 'next/link';
 
type Props = {
  params: { locale: string };
};
 
export async function generateMetadata({ params: { locale } }: Props) {
  const t = await getTranslations({ locale, namespace: 'HomePage' });
 
  return {
    title: t('title'),
    description: t('description')
  };
}
 
export default function HomePage() {
  const t = useTranslations('HomePage');
 
  return (
    <div className="max-w-4xl mx-auto">
      <h1 className="text-4xl font-bold mb-6">{t('title')}</h1>
      <p className="text-lg mb-8">{t('description')}</p>
 
      <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
        <div className="p-6 border rounded-lg">
          <h2 className="text-xl font-semibold mb-4">{t('welcome.title')}</h2>
          <p className="text-gray-600">
            This is our main landing page with internationalization support.
          </p>
        </div>
 
        <div className="p-6 border rounded-lg">
          <h3 className="text-lg font-semibold mb-2">Quick Links</h3>
          <div className="space-y-2">
            <Link href="/about" className="block text-blue-600 hover:underline">
              About Us
            </Link>
            <Link href="/products" className="block text-blue-600 hover:underline">
              Products
            </Link>
            <Link href="/contact" className="block text-blue-600 hover:underline">
              Contact
            </Link>
          </div>
        </div>
      </div>
    </div>
  );
}

app/[locale]/about/page.tsx

import { useTranslations } from 'next-intl';
import { getTranslations } from 'next-intl/server';
 
type Props = {
  params: { locale: string };
};
 
export async function generateMetadata({ params: { locale } }: Props) {
  const t = await getTranslations({ locale, namespace: 'AboutPage' });
 
  return {
    title: t('title'),
    description: t('description')
  };
}
 
export default function AboutPage() {
  const t = useTranslations('AboutPage');
 
  return (
    <div className="max-w-4xl mx-auto">
      <h1 className="text-4xl font-bold mb-6">{t('title')}</h1>
      <p className="text-lg mb-8">{t('description')}</p>
 
      <div className="prose max-w-none">
        <p className="text-gray-700 leading-relaxed">
          {t('content')}
        </p>
 
        <div className="mt-8 grid md:grid-cols-2 gap-8">
          <div>
            <h2 className="text-2xl font-semibold mb-4">Our Mission</h2>
            <p className="text-gray-600">
              To provide exceptional products and services that exceed our customers' expectations
              while maintaining the highest standards of quality and innovation.
            </p>
          </div>
 
          <div>
            <h2 className="text-2xl font-semibold mb-4">Our Vision</h2>
            <p className="text-gray-600">
              To be the leading company in our industry, recognized for our commitment to
              excellence, sustainability, and customer satisfaction.
            </p>
          </div>
        </div>
      </div>
    </div>
  );
}

app/[locale]/products/page.tsx

import { useTranslations } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import { Suspense } from 'react';
 
type Product = {
  id: number;
  name: string;
  price: number;
  image: string;
  description: string;
};
 
type Props = {
  params: { locale: string };
};
 
// Simulate API call
async function getProducts(locale: string): Promise<Product[]> {
  // Simulate network delay
  await new Promise(resolve => setTimeout(resolve, 1000));
 
  // Simulated product data that could come from a database
  const products: Record<string, Product[]> = {
    en: [
      {
        id: 1,
        name: "Premium Headphones",
        price: 199.99,
        image: "/images/headphones.jpg",
        description: "High-quality wireless headphones with noise cancellation"
      },
      {
        id: 2,
        name: "Smart Watch",
        price: 299.99,
        image: "/images/watch.jpg",
        description: "Advanced fitness tracking and smart notifications"
      },
      {
        id: 3,
        name: "Laptop Stand",
        price: 79.99,
        image: "/images/laptop-stand.jpg",
        description: "Ergonomic aluminum laptop stand for better posture"
      }
    ],
    fr: [
      {
        id: 1,
        name: "Casque Premium",
        price: 199.99,
        image: "/images/headphones.jpg",
        description: "Casque sans fil de haute qualité avec annulation de bruit"
      },
      {
        id: 2,
        name: "Montre Intelligente",
        price: 299.99,
        image: "/images/watch.jpg",
        description: "Suivi de fitness avancé et notifications intelligentes"
      },
      {
        id: 3,
        name: "Support Ordinateur Portable",
        price: 79.99,
        image: "/images/laptop-stand.jpg",
        description: "Support ergonomique en aluminium pour une meilleure posture"
      }
    ],
    nl: [
      {
        id: 1,
        name: "Premium Hoofdtelefoon",
        price: 199.99,
        image: "/images/headphones.jpg",
        description: "Hoogwaardige draadloze hoofdtelefoon met ruisonderdrukking"
      },
      {
        id: 2,
        name: "Slimme Horloge",
        price: 299.99,
        image: "/images/watch.jpg",
        description: "Geavanceerde fitness tracking en slimme notificaties"
      },
      {
        id: 3,
        name: "Laptop Standaard",
        price: 79.99,
        image: "/images/laptop-stand.jpg",
        description: "Ergonomische aluminium laptop standaard voor betere houding"
      }
    ]
  };
 
  return products[locale] || products.en;
}
 
export async function generateMetadata({ params: { locale } }: Props) {
  const t = await getTranslations({ locale, namespace: 'ProductsPage' });
 
  return {
    title: t('title'),
    description: t('description')
  };
}
 
function ProductCard({ product, locale }: { product: Product; locale: string }) {
  const t = useTranslations('ProductsPage');
 
  return (
    <div className="border rounded-lg p-6 shadow-sm hover:shadow-md transition-shadow">
      <div className="w-full h-48 bg-gray-200 rounded-lg mb-4 flex items-center justify-center">
        <span className="text-gray-500">Product Image</span>
      </div>
 
      <h3 className="text-xl font-semibold mb-2">{product.name}</h3>
      <p className="text-gray-600 mb-4">{product.description}</p>
 
      <div className="flex justify-between items-center">
        <span className="text-2xl font-bold text-green-600">
          {t('price')}: ${product.price}
        </span>
        <button className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors">
          {t('addToCart')}
        </button>
      </div>
    </div>
  );
}
 
async function ProductsList({ locale }: { locale: string }) {
  const products = await getProducts(locale);
 
  return (
    <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
      {products.map((product) => (
        <ProductCard key={product.id} product={product} locale={locale} />
      ))}
    </div>
  );
}
 
function ProductsLoading() {
  const t = useTranslations('ProductsPage');
 
  return (
    <div className="flex justify-center items-center py-12">
      <div className="text-lg">{t('loading')}</div>
    </div>
  );
}
 
export default function ProductsPage({ params: { locale } }: Props) {
  const t = useTranslations('ProductsPage');
 
  return (
    <div className="max-w-6xl mx-auto">
      <h1 className="text-4xl font-bold mb-6">{t('title')}</h1>
      <p className="text-lg mb-8">{t('description')}</p>
 
      <Suspense fallback={<ProductsLoading />}>
        <ProductsList locale={locale} />
      </Suspense>
    </div>
  );
}

app/[locale]/contact/page.tsx

import { useTranslations } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import ContactForm from '@/components/ContactForm';
 
type Props = {
  params: { locale: string };
};
 
export async function generateMetadata({ params: { locale } }: Props) {
  const t = await getTranslations({ locale, namespace: 'ContactForm' });
 
  return {
    title: t('title'),
    description: 'Contact us for any inquiries'
  };
}
 
export default function ContactPage() {
  const t = useTranslations('ContactForm');
 
  return (
    <div className="max-w-2xl mx-auto">
      <h1 className="text-4xl font-bold mb-6">{t('title')}</h1>
      <ContactForm />
    </div>
  );
}

Components

components/Navbar.tsx

import Link from 'next/link';
import { useTranslations } from 'next-intl';
import LocaleSwitcher from './LocaleSwitcher';
 
export default function Navbar() {
  const t = useTranslations('Navigation');
 
  return (
    <nav className="bg-white shadow-sm border-b">
      <div className="container mx-auto px-4">
        <div className="flex justify-between items-center h-16">
          <div className="flex items-center space-x-8">
            <Link href="/" className="text-xl font-bold">
              MyApp
            </Link>
 
            <div className="hidden md:flex space-x-6">
              <Link href="/" className="hover:text-blue-600 transition-colors">
                {t('home')}
              </Link>
              <Link href="/about" className="hover:text-blue-600 transition-colors">
                {t('about')}
              </Link>
              <Link href="/products" className="hover:text-blue-600 transition-colors">
                {t('products')}
              </Link>
              <Link href="/contact" className="hover:text-blue-600 transition-colors">
                {t('contact')}
              </Link>
            </div>
          </div>
 
          <LocaleSwitcher />
        </div>
      </div>
    </nav>
  );
}

components/LocaleSwitcher.tsx

'use client';
 
import { useLocale } from 'next-intl';
import { useRouter } from 'next/navigation';
import { usePathname } from 'next/navigation';
import { routing } from '@/i18n/routing';
 
export default function LocaleSwitcher() {
  const locale = useLocale();
  const router = useRouter();
  const pathname = usePathname();
 
  const handleLocaleChange = (newLocale: string) => {
    // Replace the current locale in the pathname
    const newPathname = pathname.replace(`/${locale}`, `/${newLocale}`);
    router.push(newPathname);
  };
 
  return (
    <select
      value={locale}
      onChange={(e) => handleLocaleChange(e.target.value)}
      className="border rounded px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
    >
      {routing.locales.map((loc) => (
        <option key={loc} value={loc}>
          {loc.toUpperCase()}
        </option>
      ))}
    </select>
  );
}

components/ContactForm.tsx

'use client';
 
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { getContactFormSchema } from '@/lib/validations';
 
export default function ContactForm() {
  const t = useTranslations('ContactForm');
  const validationMessages = useTranslations('Validation');
  const [errors, setErrors] = useState<Record<string, string>>({});
 
  // Get the schema with translated error messages
  const schema = getContactFormSchema(validationMessages);
 
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setErrors({});
 
    const formData = new FormData(e.currentTarget);
    const data = {
      name: formData.get('name') as string,
      email: formData.get('email') as string,
      message: formData.get('message') as string,
    };
 
    const result = schema.safeParse(data);
 
    if (!result.success) {
      const fieldErrors: Record<string, string> = {};
      result.error.errors.forEach((error) => {
        if (error.path[0]) {
          fieldErrors[error.path[0] as string] = error.message;
        }
      });
      setErrors(fieldErrors);
      return;
    }
 
    // Handle successful form submission
    console.log('Form submitted:', result.data);
  };
 
  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      <div>
        <label htmlFor="name" className="block text-sm font-medium mb-2">
          {t('name')}
        </label>
        <input
          type="text"
          id="name"
          name="name"
          className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
            errors.name ? 'border-red-500' : 'border-gray-300'
          }`}
        />
        {errors.name && (
          <p className="text-red-500 text-sm mt-1">{errors.name}</p>
        )}
      </div>
 
      <div>
        <label htmlFor="email" className="block text-sm font-medium mb-2">
          {t('email')}
        </label>
        <input
          type="email"
          id="email"
          name="email"
          className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
            errors.email ? 'border-red-500' : 'border-gray-300'
          }`}
        />
        {errors.email && (
          <p className="text-red-500 text-sm mt-1">{errors.email}</p>
        )}
      </div>
 
      <div>
        <label htmlFor="message" className="block text-sm font-medium mb-2">
          {t('message')}
        </label>
        <textarea
          id="message"
          name="message"
          rows={4}
          className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
            errors.message ? 'border-red-500' : 'border-gray-300'
          }`}
        />
        {errors.message && (
          <p className="text-red-500 text-sm mt-1">{errors.message}</p>
        )}
      </div>
 
      <button
        type="submit"
        className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors"
      >
        {t('submit')}
      </button>
    </form>
  );
}

lib/validations.ts

import { z } from "zod";
 
export type ContactFormValues = {
  name: string;
  email: string;
  message: string;
};
 
export function getContactFormSchema(t: any) {
  return z.object({
    name: z.string().min(1, t("nameRequired")).min(2, t("nameMin")),
    email: z.string().email(t("emailInvalid")),
    message: z.string().min(1, t("messageRequired")).min(10, t("messageMin")),
  });
}

Best Practices

1. Naming Conventions for Large Projects

Translation Keys Structure

{
  "ComponentName": {
    "section": {
      "subsection": "Translation text"
    }
  },
  "PageName": {
    "title": "Page Title",
    "description": "Page Description",
    "content": {
      "heading": "Content Heading",
      "paragraph": "Content Paragraph"
    }
  }
}

File Organization

messages/
├── common/           # Shared translations
│   ├── navigation.json
│   ├── buttons.json
│   └── validation.json
├── pages/           # Page-specific translations
│   ├── home.json
│   ├── about.json
│   └── products.json
└── components/      # Component-specific translations
    ├── forms.json
    └── modals.json
  • Pages: HomePage, AboutPage, ProductsPage
  • Components: ContactForm, LocaleSwitcher, ProductCard
  • Actions: Navigation, Buttons, Actions
  • Validation: Validation, Errors, Messages
  • Content sections: Hero, Features, Testimonials

2. Database Content Recommendations

For Database-Driven Content:

Option 1: Database Schema with Translations

-- Products table
CREATE TABLE products (
  id SERIAL PRIMARY KEY,
  sku VARCHAR(50) UNIQUE,
  price DECIMAL(10,2),
  created_at TIMESTAMP
);
 
-- Product translations table
CREATE TABLE product_translations (
  id SERIAL PRIMARY KEY,
  product_id INTEGER REFERENCES products(id),
  locale VARCHAR(5),
  name VARCHAR(255),
  description TEXT,
  meta_title VARCHAR(255),
  meta_description TEXT,
  slug VARCHAR(255),
  UNIQUE(product_id, locale)
);

Option 2: JSON Column Approach

CREATE TABLE products (
  id SERIAL PRIMARY KEY,
  sku VARCHAR(50) UNIQUE,
  price DECIMAL(10,2),
  translations JSONB, -- Store all translations in JSON
  created_at TIMESTAMP
);
 
-- Example translations JSON structure:
{
  "en": {
    "name": "Premium Headphones",
    "description": "High-quality wireless headphones",
    "slug": "premium-headphones"
  },
  "fr": {
    "name": "Casque Premium",
    "description": "Casque sans fil de haute qualité",
    "slug": "casque-premium"
  }
}

Recommended Approach for Different Scenarios:

  1. Small to Medium Projects: Use JSON column approach for simplicity
  2. Large Enterprise Projects: Use separate translation tables for better performance and complex queries
  3. Content-Heavy Applications: Consider a headless CMS like Strapi, Contentful, or Sanity with built-in i18n support

API Implementation Example:

// lib/api/products.ts
export async function getProducts(locale: string) {
  // Option 1: Separate translation table approach
  const products = await db.query(
    `
    SELECT 
      p.id, 
      p.sku, 
      p.price,
      pt.name,
      pt.description,
      pt.slug
    FROM products p
    LEFT JOIN product_translations pt ON p.id = pt.product_id
    WHERE pt.locale = $1 OR pt.locale = 'en'
    ORDER BY pt.locale = $1 DESC, p.id
  `,
    [locale]
  );
 
  // Option 2: JSON column approach
  const productsJson = await db.query(
    `
    SELECT 
      id,
      sku,
      price,
      translations->>$1 as translation_data
    FROM products
    WHERE translations ? $1
  `,
    [locale]
  );
 
  return products;
}
 
// Usage in page component
export async function getStaticProps({
  params,
}: {
  params: { locale: string };
}) {
  const products = await getProducts(params.locale);
 
  return {
    props: {
      products,
      messages: (await import(`../messages/${params.locale}.json`)).default,
    },
    revalidate: 3600, // Revalidate every hour
  };
}

3. SEO Optimization

Dynamic Metadata Generation

// app/[locale]/products/[slug]/page.tsx
export async function generateMetadata({
  params: { locale, slug },
}: {
  params: { locale: string; slug: string };
}) {
  const product = await getProductBySlug(slug, locale);
  const t = await getTranslations({ locale, namespace: "ProductsPage" });
 
  if (!product) {
    return {
      title: "Product not found",
    };
  }
 
  return {
    title: product.name,
    description: product.description,
    alternates: {
      canonical: `/${locale}/products/${product.slug}`,
      languages: {
        en: `/en/products/${product.slugs.en}`,
        fr: `/fr/produits/${product.slugs.fr}`,
        nl: `/nl/producten/${product.slugs.nl}`,
      },
    },
    openGraph: {
      title: product.name,
      description: product.description,
      url: `/${locale}/products/${product.slug}`,
      images: [product.image],
    },
  };
}

Sitemap Generation

// app/sitemap.ts
import { routing } from "@/i18n/routing";
import { getProducts } from "@/lib/api/products";
 
export default async function sitemap() {
  const baseUrl = "https://yourdomain.com";
 
  // Static pages
  const staticPages = ["", "/about", "/contact"];
  const staticUrls = routing.locales.flatMap((locale) =>
    staticPages.map((path) => ({
      url: `${baseUrl}/${locale}${path}`,
      lastModified: new Date(),
      changeFrequency: "monthly" as const,
      priority: path === "" ? 1 : 0.8,
    }))
  );
 
  // Dynamic product pages
  const productUrls = [];
  for (const locale of routing.locales) {
    const products = await getProducts(locale);
    const localeProductUrls = products.map((product) => ({
      url: `${baseUrl}/${locale}/products/${product.slug}`,
      lastModified: new Date(product.updatedAt),
      changeFrequency: "weekly" as const,
      priority: 0.6,
    }));
    productUrls.push(...localeProductUrls);
  }
 
  return [...staticUrls, ...productUrls];
}

Advanced Features

1. Language Detection and Redirects

// middleware.ts - Enhanced version
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
import { NextRequest } from "next/server";
 
export default function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;
 
  // Check if there is any supported locale in the pathname
  const pathnameIsMissingLocale = routing.locales.every(
    (locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
  );
 
  // Redirect if there is no locale
  if (pathnameIsMissingLocale) {
    // Detect locale from Accept-Language header or use default
    const locale = getLocaleFromRequest(request) || routing.defaultLocale;
 
    return Response.redirect(new URL(`/${locale}${pathname}`, request.url));
  }
 
  return createMiddleware(routing)(request);
}
 
function getLocaleFromRequest(request: NextRequest): string | undefined {
  // Get locale from Accept-Language header
  const acceptLanguage = request.headers.get("Accept-Language");
  if (!acceptLanguage) return undefined;
 
  // Simple parsing - you might want to use a library like @formatjs/intl-locale
  const preferredLocales = acceptLanguage
    .split(",")
    .map((lang) => lang.trim().split(";")[0])
    .map((lang) => lang.split("-")[0]); // Get language code only
 
  return preferredLocales.find((locale) =>
    routing.locales.includes(locale as any)
  );
}

2. Client-side Language Persistence

// hooks/useLocaleStorage.ts
"use client";
 
import { useEffect } from "react";
import { useLocale } from "next-intl";
 
export function useLocaleStorage() {
  const locale = useLocale();
 
  useEffect(() => {
    // Store the current locale in localStorage
    localStorage.setItem("preferred-locale", locale);
  }, [locale]);
}
 
// Use in your root layout
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  useLocaleStorage();
  return children;
}

3. Rich Text and Pluralization

// messages/en.json
{
  "Cart": {
    "itemCount": {
      "zero": "No items in cart",
      "one": "One item in cart",
      "other": "{count} items in cart"
    },
    "richWelcome": "Welcome <bold>{username}</bold>! You have <link>new messages</link>."
  }
}
// Component usage
import { useTranslations } from 'next-intl';
 
function CartSummary({ itemCount, username }: { itemCount: number; username: string }) {
  const t = useTranslations('Cart');
 
  return (
    <div>
      <p>{t('itemCount', { count: itemCount })}</p>
      <p>{t.rich('richWelcome', {
        username,
        bold: (chunks) => <strong>{chunks}</strong>,
        link: (chunks) => <a href="/messages" className="text-blue-600">{chunks}</a>
      })}</p>
    </div>
  );
}

4. Date, Number, and Currency Formatting

// utils/formatters.ts
import { useFormatter, useLocale } from 'next-intl';
 
export function ProductCard({ product }: { product: Product }) {
  const format = useFormatter();
  const locale = useLocale();
 
  const formattedPrice = format.number(product.price, {
    style: 'currency',
    currency: getCurrencyForLocale(locale)
  });
 
  const formattedDate = format.dateTime(product.createdAt, {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  });
 
  return (
    <div>
      <h3>{product.name}</h3>
      <p>Price: {formattedPrice}</p>
      <p>Added: {formattedDate}</p>
    </div>
  );
}
 
function getCurrencyForLocale(locale: string): string {
  const currencyMap: Record<string, string> = {
    'en': 'USD',
    'fr': 'EUR',
    'nl': 'EUR'
  };
  return currencyMap[locale] || 'USD';
}

Testing Internationalization

1. Unit Tests for Translations

// __tests__/translations.test.ts
import { getTranslations } from "next-intl/server";
 
describe("Translations", () => {
  it("should have all required keys for each locale", async () => {
    const locales = ["en", "fr", "nl"];
    const requiredKeys = [
      "Navigation.home",
      "Navigation.about",
      "HomePage.title",
      "ContactForm.submit",
    ];
 
    for (const locale of locales) {
      const t = await getTranslations({ locale });
 
      for (const key of requiredKeys) {
        const keys = key.split(".");
        let value = t;
 
        for (const k of keys) {
          value = value?.[k];
        }
 
        expect(value).toBeDefined();
        expect(typeof value).toBe("string");
      }
    }
  });
});

2. E2E Tests with Playwright

// e2e/i18n.spec.ts
import { test, expect } from "@playwright/test";
 
test.describe("Internationalization", () => {
  test("should switch languages correctly", async ({ page }) => {
    await page.goto("/en");
 
    // Check English content
    await expect(page.locator("h1")).toContainText("Welcome");
 
    // Switch to French
    await page.selectOption("select", "fr");
    await page.waitForURL("/fr");
 
    // Check French content
    await expect(page.locator("h1")).toContainText("Bienvenue");
 
    // Check URL structure
    expect(page.url()).toContain("/fr");
  });
 
  test("should maintain language preference", async ({ page }) => {
    await page.goto("/fr/about");
 
    // Navigate to another page
    await page.click('a[href="/contact"]');
 
    // Should stay in French
    await expect(page).toHaveURL("/fr/contact-moi");
  });
});

Performance Optimization

1. Message Loading Strategies

// i18n/request.ts - Optimized version
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
 
export default getRequestConfig(async ({ locale }) => {
  if (!routing.locales.includes(locale as any)) {
    notFound();
  }
 
  // Load messages dynamically based on the page
  // This reduces the initial bundle size
  const messages = await loadMessages(locale);
 
  return {
    messages,
    timeZone: getTimezoneForLocale(locale),
  };
});
 
async function loadMessages(locale: string) {
  // Load common messages
  const common = (await import(`../messages/common/${locale}.json`)).default;
 
  // Load page-specific messages based on the current route
  // This could be enhanced to load only necessary messages
  const pages = (await import(`../messages/pages/${locale}.json`)).default;
  const components = (await import(`../messages/components/${locale}.json`))
    .default;
 
  return {
    ...common,
    ...pages,
    ...components,
  };
}
 
function getTimezoneForLocale(locale: string): string {
  const timezoneMap: Record<string, string> = {
    en: "America/New_York",
    fr: "Europe/Paris",
    nl: "Europe/Amsterdam",
  };
  return timezoneMap[locale] || "UTC";
}

Conclusion

This comprehensive guide provides everything needed to implement robust internationalization in Next.js applications using English and Spanish languages. The key points to remember:

  1. Always use routing-based i18n for better SEO
  2. Structure translations hierarchically for maintainability
  3. Implement proper metadata for each locale
  4. Test thoroughly across all supported languages
  5. Optimize performance with proper caching and loading strategies

For large applications, consider using a translation management service like Lokalise, Crowdin, or Phrase to help manage translations at scale.