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
- Overview
- Why Choose Routing-based i18n
- Installation and Setup
- Project Structure
- Configuration Files
- Page Implementation
- Translation Management
- Advanced Features
- Best Practices
- 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
Cookie-based (Without Routing)
- ✅ 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
Recommended Naming Patterns
- 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:
- Small to Medium Projects: Use JSON column approach for simplicity
- Large Enterprise Projects: Use separate translation tables for better performance and complex queries
- 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:
- Always use routing-based i18n for better SEO
- Structure translations hierarchically for maintainability
- Implement proper metadata for each locale
- Test thoroughly across all supported languages
- 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.