Building multilingual applications is essential for reaching global audiences. Next.js provides robust internationalization (i18n) capabilities through its App Router architecture, enabling developers to create localized experiences that adapt content and formatting to different languages and regions. This guide covers the fundamentals of implementing i18n in Next.js using the App Router, focusing on practical patterns that work with modern full-stack TypeScript applications.
Internationalization in Next.js extends beyond simple text translation. It encompasses locale-specific formatting of dates, numbers, and plurals, proper handling of text direction for languages like Arabic and Hebrew, and URL-based routing that creates human-readable paths for each language variant. The framework's server-first approach means translations can be loaded efficiently on the server, reducing the JavaScript bundle sent to clients and improving performance.
For teams building AI-powered applications, implementing i18n early prevents costly refactoring later as features expand to new markets.
Why Next.js for Internationalization?
Next.js has emerged as the preferred framework for internationalized applications due to several architectural advantages. The App Router's support for React Server Components (RSC) allows translations to be rendered on the server without hydrating client components, resulting in smaller bundles and faster initial page loads. This approach is particularly well-documented in the next-intl documentation on Server Components, which provides comprehensive guidance on leveraging React's server-side rendering capabilities.
The framework's static and dynamic rendering capabilities work seamlessly with i18n. Static sites can generate localized versions at build time, while dynamic applications can detect user preferences at request time. This flexibility makes Next.js suitable for everything from documentation sites with a fixed set of languages to large applications supporting dozens of locales.
Key Advantages
- Server-side translation rendering for optimal performance
- Middleware-based locale detection and routing
- Support for static and dynamic rendering modes
- Strong TypeScript integration
- Comprehensive ecosystem with next-intl
Everything you need to build multilingual Next.js applications
Middleware Locale Detection
Automatic locale detection from URL, headers, and cookies with configurable priorities
Translation Management
Namespace-based organization with JSON files and integration with translation platforms
Locale-Aware Formatting
Automatic date, number, and currency formatting for each locale's conventions
Pluralization Rules
ICU message format support handles complex plural rules across 200+ languages
SEO Optimization
Automatic hreflang generation and localized metadata for search visibility
Static & Dynamic Rendering
Generate localized pages at build time or render dynamically per request
Setting Up Your i18n Infrastructure
Choosing an i18n Library
While Next.js provides basic routing support, most production applications benefit from a dedicated i18n library. next-intl has become the de facto standard for Next.js applications, offering deep integration with the App Router, Server Components, and middleware. The library handles translation loading, interpolation, and locale-aware formatting while maintaining close parity with Next.js's architecture.
Alternative libraries like next-i18n-router provide lighter-weight solutions focused primarily on routing and locale detection. For applications with simpler needs, these can reduce complexity, though they may require additional setup for advanced formatting features.
The choice depends on your application's complexity. Applications requiring comprehensive formatting (dates, numbers, pluralization) and frequent translation updates benefit from next-intl's feature set. Applications primarily needing URL-based language switching may find lighter solutions sufficient.
Our web development team has extensive experience implementing i18n solutions for clients expanding into new markets, ensuring proper architecture from the start.
Installing and Configuring next-intl
Installation requires adding the library and creating a configuration file. First, install the package:
npm install next-intl
Create an i18n.ts file that defines your locales and default language. This configuration file handles asynchronous locale detection and translation loading:
// i18n.ts
import {getRequestConfig} from 'next-intl/server';
import {routing} from './routing';
export default getRequestConfig(async ({requestLocale}) => {
const locale = await getLocale(requestLocale, routing);
return {
locale,
messages: (await import(`./messages/${locale}.json`)).default
};
});
Translation files are stored as JSON, with one file per locale (e.g., en.json, fr.json, de.json). The configuration uses next-intl/server's utilities to handle asynchronous locale detection and translation loading efficiently.
Defining Your Locales
Locales represent languages with optional regional variants. A well-structured locale configuration supports both broad languages and specific regional adaptations. The defineRouting function creates a routing configuration that specifies supported locales and custom pathnames for localized URLs:
// routing.ts
import {defineRouting} from 'next-intl/routing';
import {createNavigation} from 'next-intl/navigation';
export const routing = defineRouting({
// A list of all locales that are supported
locales: ['en', 'en-US', 'fr', 'fr-CA', 'de', 'es', 'ja', 'zh'],
// Used when no locale matches
defaultLocale: 'en',
// Strategy for localized pathnames
pathnames: {
'/services': {
en: '/services',
fr: '/services',
de: '/leistungen',
es: '/servicios'
},
'/about': {
en: '/about',
fr: '/a-propos',
de: '/ueber-uns',
es: '/sobre-nosotros'
}
}
});
This configuration supports both generic language codes (en, fr) and specific variants (en-US, fr-CA) while providing custom pathnames for localized URLs. The pathnames object enables SEO-friendly URLs that match local conventions rather than simply transliterating English paths.
Middleware and Locale Detection
How Middleware Works
Middleware is the backbone of Next.js i18n, intercepting requests before rendering to determine the appropriate locale. The middleware runs on every request, checking the URL, headers, and cookies to select the correct language:
// middleware.ts
import createMiddleware from 'next-intl/middleware';
import {routing} from './routing';
export default createMiddleware(routing);
export const config = {
// Match only internationalized pathnames
matcher: ['/', '/(de|en|fr|es|ja|zh)/:path*']
};
The middleware uses a priority system for locale detection: explicitly prefixed URLs take precedence, followed by the Accept-Language header, then cookie-based preferences, and finally the default locale. This cascading approach ensures users receive their preferred language when possible while maintaining fallback behavior.
Locale Detection Strategies
Several strategies exist for detecting user preferences, each with trade-offs. URL-based detection provides the best SEO and shareability, as the locale is visible in the URL path (e.g., /fr/services). This approach also simplifies analytics by making language segmentation straightforward.
Header-based detection uses the Accept-Language HTTP header sent by browsers, which reflects the user's system or browser language settings. While convenient, this approach can frustrate users who have set language preferences at the website level but receive different content when visiting direct links.
Cookie-based persistence remembers user language choices across sessions. When a user explicitly selects a language, storing this preference ensures consistent experiences on return visits. The middleware checks for this cookie before falling back to header-based detection.
Production applications typically combine these approaches: URL parameters for shareable links and SEO, headers for first-visit detection, and cookies for preference persistence.
Handling the Default Locale Without Prefix
A common requirement is serving the default locale at the root path (/) without a prefix while prefixing all other locales. This configuration requires additional middleware handling. The middleware checks if the pathname is missing a locale and redirects non-default locales to their prefixed paths:
// middleware.ts - Additional handling for default locale
import {routing} from './routing';
import createMiddleware from 'next-intl/middleware';
export default function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// Check if the pathname is missing a locale
const pathnameIsMissingLocale = routing.locales.every(
locale => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
);
if (pathnameIsMissingLocale) {
const locale = getLocale(request);
// Redirect to localized path for non-default locales
if (locale !== routing.defaultLocale) {
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
);
}
}
}
// Combine with the standard middleware
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
This pattern ensures /services serves English content while /de/services serves German, maintaining clean URLs for the default locale while preserving language clarity for other locales.
Translation Files and Message Loading
Organizing Translation Files
Translation files should be organized for maintainability and performance. A flat structure works for small applications, but hierarchical organization scales better:
messages/
├── en.json
├── fr.json
├── de.json
├── es.json
├── ja.json
├── zh.json
├── en-US.json
└── fr-CA.json
Each file contains key-value pairs where keys remain consistent across languages. The namespace parameter allows organizing translations into logical groups, preventing naming conflicts and improving performance by only loading needed translations:
// en.json
{
"Navigation": {
"home": "Home",
"services": "Services",
"about": "About Us",
"contact": "Contact"
},
"Hero": {
"title": "Building Digital Experiences",
"subtitle": "We craft modern web applications that scale.",
"cta": "Get Started"
}
}
// de.json
{
"Navigation": {
"home": "Startseite",
"services": "Leistungen",
"about": "Über uns",
"contact": "Kontakt"
},
"Hero": {
"title": "Digitale Erlebnisse gestalten",
"subtitle": "Wir entwickeln moderne Webanwendungen.",
"cta": "Loslegen"
}
}
Using Translations in Components
Server Components can access translations directly using getTranslations. This function is async and runs on the server, keeping translation data out of the client bundle:
// app/[locale]/page.tsx
import {getTranslations} from 'next-intl/server';
export default async function HomePage({params: {locale}}: PageProps) {
const t = await getTranslations({locale, namespace: 'Hero'});
return (
<section>
<h1>{t('title')}</h1>
<p>{t('subtitle')}</p>
<button>{t('cta')}</button>
</section>
);
}
Client components use the useTranslations hook instead. The hook is marked with 'use client' and provides translations at runtime:
// components/LanguageSelector.tsx
'use client';
import {useTranslations} from 'next-intl/client';
export default function LanguageSelector({locale}: {locale: string}) {
const t = useTranslations('LanguageSelector');
return (
<select defaultValue={locale}>
<option value="en">{t('english')}</option>
<option value="fr">{t('french')}</option>
<option value="de">{t('german')}</option>
</select>
);
}
Interpolation and Variables
Dynamic content requires interpolation, which keeps translations flexible. The ICU message format, supported by next-intl, handles variable interpolation, number formatting, and complex pluralization rules:
// en.json
{
"Greeting": "Hello, {name}!",
"Price": "The price is {price, number, USD}",
"Items": "You have {count, plural, =0 {no items} one {# item} other {# items}}",
"Notification": "{username} sent {count, plural, one {# message} other {# messages}}"
}
The interpolation syntax {name} inserts variables into translations. The # symbol in plural rules is replaced with the count value. The number formatter applies locale-aware number formatting with currency and other style options. This approach eliminates conditional logic in templates and handles complex grammatical rules across languages automatically.
Locale-Aware Formatting
Date and Time Formatting
Dates and times must adapt to local conventions. Next.js i18n provides formatting functions that respect locale-specific patterns for month names, date ordering, and time formats:
import {getFormatter} from 'next-intl/server';
export default async function EventDate({locale}: {locale: string}) {
const format = await getFormatter({locale});
const date = new Date('2025-03-15');
return (
<time dateTime={date.toISOString()}>
{format.dateTime(date, {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
);
}
This renders as "March 15, 2025" for English, "15. März 2025" for German, "15 mars 2025" for French, and "2025年3月15日" for Chinese. The formatter automatically adapts month names, date ordering (DMY vs MDY), and calendar systems based on the locale.
Number and Currency Formatting
Similar to dates, numbers and currencies require locale-aware formatting. The Intl.NumberFormat API handles thousands separators, decimal marks, and currency symbol placement automatically:
const formatter = await getFormatter({locale});
// Numbers with different separators
formatter.number(1234567.89);
// en: "1,234,567.89"
// de: "1.234.567,89"
// fr: "1 234 567,89"
// ja: "1,234,567.89"
// Currency with symbol placement
formatter.number(99.99, {style: 'currency', currency: 'USD'});
// en: "$99.99"
// de: "99,99 $"
// fr: "99,99 $"
// Euro with correct symbol position
formatter.number(149.50, {style: 'currency', currency: 'EUR'});
// de: "149,50 €"
// fr: "149,50 €"
Pluralization Rules
Languages handle plurals differently. English has two forms (one, other), while Arabic has six distinct plural categories. The ICU message format handles these rules automatically without conditional logic:
{
"notification_one": "You have {count, plural, one {# notification} other {# notifications}}",
"items_sold": "{count, plural, =0 {No items sold} one {# item sold} other {# items sold}}",
"arabic_items": "{count, plural, zero {#} one {#} two {#} few {#} many {#} other {#}}"
}
The # symbol is replaced with the count value, and the correct grammatical form is selected based on the locale's plural rules. This eliminates the need for conditional logic in templates and ensures grammatically correct output across all supported languages.
Navigation and Routing
Creating Localized Links
Navigation components must generate correct URLs for the current locale. The createLocalizedPathnamesNavigation function creates typed navigation utilities that automatically handle locale prefixes:
import {createLocalizedPathnamesNavigation} from 'next-intl/navigation';
import {routing} from './routing';
export const {Link, redirect, usePathname, useRouter} =
createLocalizedPathnamesNavigation(routing);
The Link component automatically prefixes URLs with the current locale. When the user is on a French page, clicking the services link navigates to /fr/services automatically:
import {Link} from '@/i18n/routing';
export default function Navigation() {
return (
<nav>
<Link href="/">{t('home')}</Link>
<Link href="/services">{t('services')}</Link>
<Link href="/about">{t('about')}</Link>
</nav>
);
}
Switching Locales
Language switching requires updating both the URL and storing the preference in a cookie. The router.replace method updates the URL without adding a history entry:
'use client';
import {useRouter, usePathname} from '@/i18n/routing';
export default function LanguageSwitcher() {
const router = useRouter();
const pathname = usePathname();
function onSelectChange(event: React.ChangeEvent<HTMLSelectElement>) {
router.replace(pathname, {locale: event.target.value});
}
return (
<select onChange={onSelectChange}>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
<option value="es">Español</option>
</select>
);
}
Static Parameters for Build-Time Localization
Static generation requires telling Next.js which locales to prerender at build time. The generateStaticParams function returns all supported locales:
// app/[locale]/page.tsx
export function generateStaticParams() {
return routing.locales.map((locale) => ({locale}));
}
export default async function Page({params: {locale}}: PageProps) {
const t = await getTranslations({locale, namespace: 'Page'});
return (
<article>
<h1>{t('title')}</h1>
<div>{t('content')}</div>
</article>
);
}
This generates a static page for each locale at build time, improving performance for content that doesn't change frequently. Each locale gets its own prerendered HTML file, served instantly without server-side processing.
Performance Optimization
Translation Loading Strategies
Translation loading impacts both initial load time and runtime performance. Next-intl supports several strategies optimized for different use cases.
Build-time loading bundles translations with the application, eliminating runtime fetch overhead. This works well for applications with small translation sets or infrequent updates.
Runtime lazy loading fetches translations only when needed, reducing initial bundle size. Next-intl automatically lazy-loads translations in Server Components, sending only the messages needed for the current page. This approach significantly reduces the JavaScript bundle sent to clients, as translations never ship to the browser--only server-rendered HTML.
// Only loads French messages when rendering French page
const t = await getTranslations({locale: 'fr', namespace: 'HomePage'});
Middleware Performance Considerations
Middleware performance becomes critical with many locales. Profiling reveals that middleware execution time scales with locale count due to iteration through locales for matching:
| Locales | Overhead |
|---|---|
| 2-5 | 1-3ms |
| 10-20 | 5-15ms |
| 100+ | 100-180ms |
Strategies to mitigate performance impact include:
- Caching middleware results using the
localeCookieoption to avoid repeated detection on subsequent requests - Limiting supported locales to essential markets rather than supporting every possible language variant
- Domain-based routing for large locale sets, where each locale uses a separate subdomain (fr.example.com vs example.com/fr)
- Precomputing static paths at build time to bypass middleware entirely for known routes
Optimizing Bundle Size
Client-side translation code can impact bundle size, but the architecture minimizes this impact. Client Components using useTranslations import a small runtime, while translation data itself remains on the server:
// This hook runs entirely on the server, no translation data ships to client
import {useTranslations} from 'next-intl/client';
export default function Button({label}: {label: string}) {
const t = useTranslations('Button');
return <button>{t(label)}</button>;
}
For complex applications, code splitting by route ensures only necessary translations load for each page. The namespace parameter enables granular loading--only import the translations each component actually needs rather than a monolithic translation file.
SEO and Metadata for Multilingual Sites
Hreflang Implementation
Search engines require hreflang annotations to understand language variants and serve the correct version in search results. The <link> tags specify alternate language versions for each page. Proper implementation of hreflang is essential for SEO services targeting international markets.
// app/[locale]/layout.tsx
export async function generateStaticParams() {
return routing.locales.map((locale) => ({locale}));
}
export default function LocaleLayout({
children,
params: {locale}
}: LayoutProps) {
return (
<html lang={locale}>
<head>
{routing.locales.map((alternateLocale) => (
<link
key={alternateLocale}
rel="alternate"
hrefLang={alternateLocale}
href={`https://example.com/${alternateLocale}${pathname}`}
/>
))}
<link
rel="alternate"
hrefLang="x-default"
href={`https://example.com${pathname}`}
/>
</head>
{children}
</html>
);
}
The x-default hreflang tag indicates the page to show when no language matches, typically the English version or a language selector page. Proper hreflang implementation ensures search engines index the correct language variant.
Canonical URLs and Metadata
Each locale variant needs proper canonical URLs and metadata for search engines to understand the relationship between language variants:
// app/[locale]/page.tsx
export async function generateMetadata({params: {locale}}: PageProps) {
const t = await getTranslations({locale, namespace: 'Metadata'});
return {
title: {
default: t('pageTitle'),
template: t('titleTemplate')
},
description: t('description'),
alternates: {
canonical: `https://example.com/${locale}/page-slug`,
languages: {
'en': 'https://example.com/en/page-slug',
'fr': 'https://example.com/fr/page-slug',
'de': 'https://example.com/de/page-slug',
'es': 'https://example.com/es/page-slug'
}
}
};
}
The alternates.canonical specifies the preferred URL for this page, while alternates.languages maps all available language variants. This structure consolidates ranking signals across language variants and prevents duplicate content issues.
Common Patterns and Best Practices
Error Pages and Boundary Handling
Error pages must also support localization to maintain a consistent experience when users encounter 404 or 500 errors. Create localized error pages in the [locale] segment:
// app/[locale]/not-found.tsx
import {getTranslations} from 'next-intl/server';
import {Link} from '@/i18n/routing';
export default async function NotFoundPage({params: {locale}}: PageProps) {
const t = await getTranslations({locale, namespace: 'NotFound'});
return (
<div className="not-found">
<h1>{t('title')}</h1>
<p>{t('description')}</p>
<Link href="/">{t('goHome')}</Link>
</div>
);
}
Testing Internationalized Applications
Testing i18n functionality requires running tests with different locales to verify translations render correctly. The NextIntlClientProvider wraps components with locale-specific messages:
// tests/homepage.test.tsx
import {render, screen} from '@testing-library/react';
import HomePage from '@/app/[locale]/page';
import {NextIntlClientProvider} from 'next-intl';
async function renderWithLocale(locale: string, Component: React.ComponentType) {
const messages = await import(`@/messages/${locale}.json`);
render(
<NextIntlClientProvider locale={locale} messages={messages.default}>
<Component params={{locale}} />
</NextIntlClientProvider>
);
}
test('renders heading in English', async () => {
await renderWithLocale('en', HomePage);
expect(screen.getByRole('heading')).toHaveTextContent('Welcome');
});
test('renders heading in French', async () => {
await renderWithLocale('fr', HomePage);
expect(screen.getByRole('heading')).toHaveTextContent('Bienvenue');
});
Managing Translation Workflows
For larger teams, translation management platforms like Crowdin, Lokalise, or POEditor integrate with the JSON file structure. These platforms provide collaborative translation interfaces, translation memory for consistency, contextual screenshots for translators, automated sync with repositories, and translation review workflows.
Fallback Strategies
When translations are missing, configure fallback behavior to ensure users see meaningful content. The configuration specifies which locale to use as a fallback:
// next.config.js
const withNextIntl = createNextIntlPlugin();
module.exports = withNextIntl({
experimental: {
// Use English fallbacks for missing translations
fallbackLng: {default: ['en']}
}
});
This ensures users see English content when a translation is missing in their language. During development, log missing translation keys to identify gaps in coverage before deploying to production.
Summary
Implementing internationalization in Next.js requires coordination between routing, middleware, translation loading, and formatting. The App Router's server-first architecture provides an excellent foundation, with next-intl offering the most complete i18n solution for modern Next.js applications.
Key Takeaways
- Use middleware for locale detection with proper fallback strategies that combine URL, header, and cookie-based detection
- Organize translations by namespace for maintainability and performance, loading only what each component needs
- Leverage Server Components to keep translation logic on the server, minimizing client bundle size
- Implement proper SEO signals with hreflang annotations and localized metadata for search visibility
- Monitor performance as applications scale with more locales, using caching and domain routing for large deployments
Performance considerations become critical at scale. Middleware execution time increases with locale count, so implement caching strategies and consider domain-based routing for applications supporting dozens of locales. Translation loading in Server Components keeps bundles small, but client-side components should be tested thoroughly across all supported languages.
For teams ready to implement i18n at scale, our web development services include comprehensive internationalization planning and implementation to help you reach global audiences effectively.
Frequently Asked Questions
What is the difference between i18n and l10n?
Internationalization (i18n) is the process of designing software so it can be adapted to various languages and regions. Localization (l10n) is the actual adaptation of content, translations, and formatting for a specific locale. i18n creates the infrastructure; l10n fills it with localized content. Think of i18n as building the framework, while l10n provides the localized content.
How many locales should I support initially?
Start with the languages of your primary markets. Most applications begin with 2-5 locales and expand based on user demand and business priorities. Each additional locale adds maintenance overhead and translation costs. Supporting more locales also impacts middleware performance and increases testing requirements.
Should I use subdomains or subpaths for locales?
Subpaths (example.com/fr/) are generally preferred for SEO as they consolidate domain authority. Subdomains (fr.example.com) may be necessary for technical or regulatory reasons. Next.js supports both approaches through routing configuration. Consider your hosting infrastructure and CDN when making this decision.
How do I handle right-to-left languages?
Next.js automatically applies the dir="rtl" attribute when the locale's language direction is right-to-left. CSS frameworks like Tailwind provide RTL variants (rtl: for Arabic, Hebrew, Persian). Test thoroughly as layouts may need adjustments for RTL rendering.
What happens if a translation is missing?
By default, next-intl falls back to the default locale's translation. You can configure custom fallback behavior, log missing keys during development, and use translation management platforms to ensure complete coverage. Regular translation audits help maintain full coverage across all supported languages.
Sources
- Next.js App Router Internationalization Guide - Official Next.js documentation on i18n configuration, routing, and rendering strategies
- next-intl Getting Started - Official library documentation covering translation rendering, date/number formatting, and routing
- next-intl Middleware Documentation - Locale detection, routing configuration, and middleware patterns
- LogRocket: Complete Guide to Next.js i18n - In-depth tutorial covering setup, routing patterns, and performance considerations
- POEditor: Next.js i18n Guide - Translation management integration patterns
- This Dot Labs: Next.js i18n with next-intl - App Router integration walkthrough