Switch Language
Toggle Theme

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-intl

2. 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.json

messages/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.tsx

After 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);
};

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.json

Then 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.ts

navigation.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:

  1. Core Configuration: Middleware + i18n.ts + [locale] directory
  2. Translation Usage: useTranslations Hook works in both Server and Client Components
  3. Routing: Use Link and Router provided by next-intl
  4. File Management: Split by module + TypeScript type checking
  5. 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

Hope this article helps you who are struggling with Next.js internationalization!

FAQ

Why use next-intl instead of built-in i18n?
Next.js App Router removed built-in i18n support.

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?
Steps:
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?
Methods:
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?
Best practices:
• 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?
Use useTranslations hook:

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?
next-intl supports:
• 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?
Optimization tips:
• 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

Comments

Sign in with GitHub to leave a comment

Related Posts