Next.js Internationalization Complete Guide: next-intl Best Practices

Next.js Internationalization Complete Guide: next-intl Best Practices
Last year I took over a Next.js project that needed multi-language support. Looking at the various i18n settings in the config files, I was honestly a bit confused. After digging through documentation, I discovered that App Router and the old Pages Router have completely different approaches to internationalization. It took me a week to get next-intl configured properly, and I stepped into quite a few pitfalls along the way.
Today let’s talk about Next.js internationalization solutions, especially how to elegantly implement multi-language support using next-intl in App Router.
Why Choose next-intl?
You might ask, doesn’t Next.js have built-in internationalization? Yes, in the Pages Router era, Next.js had built-in i18n routing support. But with App Router, this feature was removed.
The official recommendation is: use a third-party library. And next-intl is one of the most popular choices.
Advantages of next-intl:
- Native App Router Support - Designed specifically for App Router, very convenient to use
- Type Safety - Works with TypeScript to provide type checking for translation text
- Flexible Routing Solutions - Supports sub-paths, domains, cookies, and other language switching methods
- Powerful Features - Supports plurals, date formatting, number formatting, rich text, etc.
- Excellent Performance - Server Component friendly, supports static rendering
Honestly, compared to other solutions, next-intl’s documentation is quite clear, and getting started isn’t too painful.
Basic Configuration: From Scratch
1. Install Dependencies
First, of course, install next-intl:
npm install next-intl
# or
pnpm add next-intl
# or
yarn add next-intl2. Create Translation Files
Create a messages folder in the project root (you can also call it locales or something else), then create JSON files by language:
messages/
├── en.json
├── zh.json
└── ja.jsonmessages/zh.json:
{
"HomePage": {
"title": "欢迎来到我的网站",
"description": "这是一个支持多语言的 Next.js 应用"
},
"Navigation": {
"home": "首页",
"about": "关于",
"contact": "联系我们"
}
}messages/en.json:
{
"HomePage": {
"title": "Welcome to My Website",
"description": "This is a multilingual Next.js application"
},
"Navigation": {
"home": "Home",
"about": "About",
"contact": "Contact Us"
}
}This nested structure isn’t required, but I find organizing by page or component makes management much easier.
3. Configure i18n.ts
Create i18n.ts (or i18n/config.ts) to configure supported languages:
import { getRequestConfig } from 'next-intl/server';
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`./messages/${locale}.json`)).default
}));This configuration tells next-intl where to find translation files. The locale parameter is automatically extracted from the URL.
4. Create Middleware
Create middleware.ts in the project root—this is key for handling multi-language routing:
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
// Supported language list
locales: ['en', 'zh', 'ja'],
// Default language
defaultLocale: 'zh',
// Whether to always show default language in URL
localePrefix: 'as-needed'
});
export const config = {
// Match all paths except api, _next/static, _next/image, favicon.ico
matcher: ['/', '/(zh|en|ja)/:path*', '/((?!api|_next|_next/static|_next/image|favicon.ico).*)']
};About localePrefix options:
'always'- All languages show prefix, including default language (/zh/about,/en/about)'as-needed'- Default language doesn’t show prefix (/about,/en/about)'never'- All languages don’t show prefix (need other way to identify language, like domain)
I usually use 'as-needed' because it’s friendly to Chinese users and URLs look cleaner.
5. Adjust app Directory Structure
This is the most critical step. You need to put all routes under the [locale] dynamic route:
Previous structure:
app/
├── page.tsx
├── about/
│ └── page.tsx
└── layout.tsxAfter adjustment:
app/
├── [locale]/
│ ├── page.tsx
│ ├── about/
│ │ └── page.tsx
│ └── layout.tsx
└── layout.tsx (optional, for global config)6. Configure Root Layout
Configure language in app/[locale]/layout.tsx:
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
const locales = ['en', 'zh', 'ja'];
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params: { locale }
}: {
children: React.ReactNode;
params: { locale: string };
}) {
// Validate if language is supported
if (!locales.includes(locale)) {
notFound();
}
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}Note the generateStaticParams here—if you use static generation, this function tells Next.js which languages need pages generated.
Using Translations in Components
After configuration, you can use translations in components.
In Server Component
import { useTranslations } from 'next-intl';
export default function HomePage() {
const t = useTranslations('HomePage');
return (
<div>
<h1>{t('title')}</h1>
<p>{t('description')}</p>
</div>
);
}The parameter for useTranslations is the namespace in the translation file (the outermost key). If you don’t pass a parameter, you can use the full path like t('HomePage.title').
In Client Component
Usage in Client Component is exactly the same:
'use client';
import { useTranslations } from 'next-intl';
export default function Navigation() {
const t = useTranslations('Navigation');
return (
<nav>
<a href="/">{t('home')}</a>
<a href="/about">{t('about')}</a>
<a href="/contact">{t('contact')}</a>
</nav>
);
}This is the elegance of next-intl—Server and Client Component usage is consistent.
Multi-Language Routing
Get Current Language
import { useLocale } from 'next-intl';
export default function LanguageSwitcher() {
const locale = useLocale();
return <div>Current language: {locale}</div>;
}Create Language Switcher
This is a feature every internationalized website needs:
'use client';
import { useLocale } from 'next-intl';
import { usePathname, useRouter } from 'next/navigation';
export default function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const switchLanguage = (newLocale: string) => {
// Replace language part in path
const newPath = pathname.replace(`/${locale}`, `/${newLocale}`);
router.push(newPath);
};
return (
<select value={locale} onChange={(e) => switchLanguage(e.target.value)}>
<option value="zh">中文</option>
<option value="en">English</option>
<option value="ja">日本語</option>
</select>
);
}However, this solution has a small issue: if the user is on the default language (e.g., Chinese) page, URL is /about, after switching to English it should be /en/about. The above code needs improvement:
const switchLanguage = (newLocale: string) => {
// Remove current language prefix
let path = pathname;
if (pathname.startsWith(`/${locale}`)) {
path = pathname.substring(locale.length + 1);
}
// Add new language prefix (unless it's default language and configured as as-needed)
const newPath = newLocale === 'zh' ? path : `/${newLocale}${path}`;
router.push(newPath);
};Using Link Component
next-intl provides an enhanced Link component that automatically handles language prefixes:
import { Link } from '@/navigation'; // Need to configure first
<Link href="/about">
{t('about')}
</Link>Configure navigation.ts:
import { createSharedPathnamesNavigation } from 'next-intl/navigation';
export const locales = ['en', 'zh', 'ja'] as const;
export const localePrefix = 'as-needed';
export const { Link, redirect, usePathname, useRouter } =
createSharedPathnamesNavigation({ locales, localePrefix });This way, exported Link, useRouter, etc. will automatically handle language paths.
Advanced Features
1. Translations with Parameters
Translation content often needs to insert variables, next-intl supports this:
messages/zh.json:
{
"welcome": "欢迎回来,{username}!",
"items": "你有 {count} 个新消息"
}Usage:
const t = useTranslations();
<p>{t('welcome', { username: 'John' })}</p>
<p>{t('items', { count: 5 })}</p>2. Plural Handling
Different languages have different plural rules, next-intl provides t.rich to handle this:
messages/en.json:
{
"messages": {
"one": "You have {count} message",
"other": "You have {count} messages"
}
}Usage:
t('messages', { count: 1 }) // "You have 1 message"
t('messages', { count: 5 }) // "You have 5 messages"Chinese doesn’t have plural concepts, you can write it like this:
messages/zh.json:
{
"messages": "你有 {count} 条消息"
}3. Date and Number Formatting
next-intl provides dedicated formatting functions:
import { useFormatter } from 'next-intl';
export default function DateExample() {
const format = useFormatter();
const now = new Date();
return (
<div>
<p>{format.dateTime(now, { dateStyle: 'full' })}</p>
{/* Chinese: 2025年12月25日星期三 */}
{/* English: Wednesday, December 25, 2025 */}
<p>{format.number(1234567.89, { style: 'currency', currency: 'CNY' })}</p>
{/* Chinese: ¥1,234,567.89 */}
{/* English: CN¥1,234,567.89 */}
</div>
);
}4. Rich Text Translations
Sometimes translation content needs to include HTML tags or React components:
messages/zh.json:
{
"richText": "我同意<terms>服务条款</terms>和<privacy>隐私政策</privacy>"
}Usage:
import { useTranslations } from 'next-intl';
export default function Agreement() {
const t = useTranslations();
return (
<p>
{t.rich('richText', {
terms: (chunks) => <a href="/terms">{chunks}</a>,
privacy: (chunks) => <a href="/privacy">{chunks}</a>
})}
</p>
);
}Translation File Management Best Practices
As projects grow, translation files become harder to manage. Here are some practical tips:
1. Split by Feature Module
Don’t stuff all translations into one big JSON file, split by page or feature:
messages/
├── zh/
│ ├── common.json # Common translations (buttons, error messages, etc.)
│ ├── home.json # Homepage
│ ├── about.json # About page
│ └── auth.json # Auth related
├── en/
│ ├── common.json
│ ├── home.json
│ ├── about.json
│ └── auth.jsonThen merge in i18n.ts:
import { getRequestConfig } from 'next-intl/server';
export default getRequestConfig(async ({ locale }) => {
const common = (await import(`./messages/${locale}/common.json`)).default;
const home = (await import(`./messages/${locale}/home.json`)).default;
const about = (await import(`./messages/${locale}/about.json`)).default;
const auth = (await import(`./messages/${locale}/auth.json`)).default;
return {
messages: {
common,
home,
about,
auth
}
};
});2. Use TypeScript Type Checking
This is a feature I find particularly useful. Define translation file types to prevent typos:
types/i18n.ts:
import zh from '@/messages/zh.json';
type Messages = typeof zh;
declare global {
interface IntlMessages extends Messages {}
}Configure TypeScript (tsconfig.json):
{
"compilerOptions": {
"types": ["./types/i18n"]
}
}This way, when using t('xxx'), if the key doesn’t exist, TypeScript will error. Very practical!
3. Extract Common Translations
Some common text (like “Confirm”, “Cancel”, “Save”) will be used in many places, suggest managing separately:
messages/zh/common.json:
{
"actions": {
"save": "保存",
"cancel": "取消",
"delete": "删除",
"confirm": "确认",
"edit": "编辑"
},
"status": {
"success": "操作成功",
"error": "操作失败",
"loading": "加载中..."
}
}Usage:
const t = useTranslations('common.actions');
<button>{t('save')}</button>4. Use Translation Management Tools
After projects get large, manually maintaining JSON files becomes painful. Consider these tools:
- Tolgee - Open-source translation management platform with real-time editing
- Localazy - Automated translation workflows
- i18n Ally (VSCode extension) - Manage translations directly in the editor
I mainly use i18n Ally now—can see translation content directly while coding, easy to modify.
5. Handling Missing Translations
During development, you often encounter situations where translations for a language aren’t complete. You can configure fallback logic:
// i18n.ts
export default getRequestConfig(async ({ locale }) => {
const messages = (await import(`./messages/${locale}.json`)).default;
const fallback = locale !== 'zh'
? (await import(`./messages/zh.json`)).default
: {};
return {
messages: {
...fallback,
...messages
}
};
});This way, if English translation is missing, it automatically falls back to Chinese.
Performance Optimization
1. Code Splitting
If translation files are large, you can load on demand:
// Only load when needed
export default function AdminPage() {
const t = useTranslations('admin'); // Only load admin namespace
// ...
}2. Static Generation
For pages that don’t change often, using static generation can significantly improve performance:
// app/[locale]/about/page.tsx
export const dynamic = 'force-static';
export function generateStaticParams() {
return [
{ locale: 'zh' },
{ locale: 'en' },
{ locale: 'ja' }
];
}3. Translation Preloading
For above-the-fold content, you can preload translation files:
import { getTranslations } from 'next-intl/server';
// Preload on server
export default async function Home() {
const t = await getTranslations('HomePage');
return <h1>{t('title')}</h1>;
}Common Issues and Solutions
1. Dynamic Route Language Switching
Dynamic routes (like /blog/[slug]) need to maintain slug when switching languages:
const switchLanguage = (newLocale: string) => {
const segments = pathname.split('/').filter(Boolean);
// Remove old language prefix
if (['zh', 'en', 'ja'].includes(segments[0])) {
segments.shift();
}
// Add new language prefix (if needed)
if (newLocale !== 'zh' || localePrefix === 'always') {
segments.unshift(newLocale);
}
router.push('/' + segments.join('/'));
};2. SEO Optimization
Multi-language websites need special attention to SEO:
// app/[locale]/layout.tsx
export async function generateMetadata({ params: { locale } }) {
const t = await getTranslations({ locale, namespace: 'metadata' });
return {
title: t('title'),
description: t('description'),
alternates: {
canonical: `https://example.com/${locale}`,
languages: {
'zh-CN': 'https://example.com/zh',
'en-US': 'https://example.com/en',
'ja-JP': 'https://example.com/ja'
}
}
};
}3. Language Detection
Automatically detect user language on first visit:
// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { NextRequest } from 'next/server';
const intlMiddleware = createMiddleware({
locales: ['en', 'zh', 'ja'],
defaultLocale: 'zh',
localeDetection: true // Enable auto-detection
});
export default function middleware(request: NextRequest) {
return intlMiddleware(request);
}next-intl will automatically select language based on the Accept-Language request header.
4. Persist Language Preference
After user selects language, it’s best to remember this choice:
// Use Cookie to save
const switchLanguage = (newLocale: string) => {
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`;
router.push(newPath);
};next-intl’s middleware will automatically read this Cookie.
Practical Case: Complete Internationalization Project
Finally, I’ve organized key code from a small project I did before, for reference.
Project structure:
├── app/
│ ├── [locale]/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── blog/
│ │ └── [slug]/
│ │ └── page.tsx
├── components/
│ ├── LanguageSwitcher.tsx
│ └── Navigation.tsx
├── messages/
│ ├── zh/
│ │ ├── common.json
│ │ └── blog.json
│ ├── en/
│ │ ├── common.json
│ │ └── blog.json
├── i18n.ts
├── middleware.ts
└── navigation.tsnavigation.ts (routing config):
import { createSharedPathnamesNavigation } from 'next-intl/navigation';
export const locales = ['zh', 'en'] as const;
export const localePrefix = 'as-needed';
export const { Link, redirect, usePathname, useRouter } =
createSharedPathnamesNavigation({ locales, localePrefix });components/Navigation.tsx:
'use client';
import { Link } from '@/navigation';
import { useTranslations } from 'next-intl';
import LanguageSwitcher from './LanguageSwitcher';
export default function Navigation() {
const t = useTranslations('common.navigation');
return (
<nav className="flex items-center justify-between p-4">
<div className="flex gap-4">
<Link href="/">{t('home')}</Link>
<Link href="/blog">{t('blog')}</Link>
<Link href="/about">{t('about')}</Link>
</div>
<LanguageSwitcher />
</nav>
);
}After this project went live, language switching was very smooth, no issues encountered.
Summary
Next.js internationalization in the App Router era is indeed a bit complex, but after mastering next-intl, it’s actually not difficult:
- Core Configuration: Middleware + i18n.ts +
[locale]directory - Translation Usage:
useTranslationsHook works in both Server and Client Components - Routing: Use Link and Router provided by next-intl
- File Management: Split by module + TypeScript type checking
- Performance Optimization: Static generation + on-demand loading
Honestly, when I first encountered this solution, it felt quite convoluted, especially the middleware and dynamic routing parts. But after writing it a few times, I got used to it. Now when doing internationalization projects, I basically follow this pattern.
If you’re working on or planning a multi-language project, I strongly recommend trying next-intl. Although there’s a learning curve, it’s worth investing time in the long run.
Reference Resources
- next-intl Official Documentation
- Next.js Internationalization Guide
- i18n Ally VSCode Extension
- Tolgee Translation Management Platform
Hope this article helps you who are struggling with Next.js internationalization!
FAQ
Why use next-intl instead of built-in i18n?
next-intl advantages:
• Native App Router support
• Type-safe with TypeScript
• Flexible routing solutions
• Powerful features (plurals, date formatting)
• Excellent performance
Built-in i18n only works in Pages Router, not App Router.
How do I set up next-intl?
1) Install next-intl
2) Create middleware for locale detection
3) Set up routing structure [locale]
4) Create translation files
5) Configure next-intl provider
Example:
npm install next-intl
Create app/[locale]/layout.tsx
Create messages/en.json, messages/zh.json
How do I switch languages?
1) URL-based: /en/about, /zh/about
2) Domain-based: en.example.com, zh.example.com
3) Cookie-based: Store locale in cookie
4) Header-based: Use Accept-Language header
Most common: URL-based routing with [locale] segment.
How do I handle translation files?
• Organize by feature/namespace
• Use nested structure
• Keep translations in JSON files
• Use TypeScript for type safety
Example:
messages/
en.json
zh.json
en/
common.json
home.json
zh/
common.json
home.json
How do I use translations in components?
Example:
'use client'
import { useTranslations } from 'next-intl'
function Component() {
const t = useTranslations('HomePage')
return <h1>{t('title')}</h1>
}
For Server Components, use getTranslations.
How do I handle plurals and formatting?
• Plurals: t('items', { count: 5 })
• Date formatting: formatDate(date, { dateStyle: 'long' })
• Number formatting: formatNumber(1234.56)
• Rich text: t.rich('text', { bold: (chunks) => <strong>{chunks}</strong> })
Configure in next-intl config file.
How do I optimize performance?
• Use Server Components when possible
• Lazy load translation files
• Use namespaces to split translations
• Cache translations
• Minimize translation file size
next-intl is Server Component friendly and supports static rendering.
7 min read · Published on: Dec 25, 2025 · Modified on: Jan 22, 2026
Related Posts
Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation

Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation
Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload

Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload
Next.js Unit Testing Guide: Complete Jest + React Testing Library Setup


Comments
Sign in with GitHub to leave a comment