0

Tích hợp đa ngôn ngữ vào Next.js (App Router)

image.png

Giới thiệu

Khi xây dựng các dự án hướng đến thị trường toàn cầu hoặc các dịch vụ cần hỗ trợ nhiều ngôn ngữ (Anh-Việt, Anh-Nhật...), việc thiết kế hệ thống i18n (internationalization) là một quyết định quan trọng ngay từ đầu.

Trong hệ sinh thái Next.js App Router hiện nay có khá nhiều thư viện i18n, nhưng nếu xét về type safety, khả năng tích hợp với Server Components và tính linh hoạt trong routing thì next-intl là lựa chọn cân bằng nhất ở thời điểm hiện tại.

Bài viết này dựa trên tài liệu chính thức của next-intl, đi từ bước cài đặt cơ bản đến các use case thực tế thường gặp trong dự án.


Yêu cầu

  • Next.js 14 trở lên (App Router)
  • TypeScript
  • next-intl v3 trở lên

Tổng quan kiến trúc

next-intl cung cấp hai hướng tiếp cận:

① Cấu hình đơn giản (không routing) Ngôn ngữ được chuyển đổi qua Cookie hoặc logic tùy chỉnh. URL không có prefix ngôn ngữ. Phù hợp để bắt đầu nhanh.

② Cấu hình routing theo locale (khuyến nghị cho production) URL được phân tách theo ngôn ngữ như /en/about, /vi/about. Tốt cho SEO và trải nghiệm người dùng.

Bài viết sẽ hướng dẫn theo thứ tự: cấu hình đơn giản trước để nắm được cách hoạt động, sau đó chuyển sang cấu hình routing đầy đủ.


Phần 1: Cấu hình đơn giản (không routing)

Cài đặt

npm install next-intl

Cấu trúc thư mục

├── messages/
│   ├── en.json
│   └── vi.json
├── next.config.ts
└── src/
    ├── i18n/
    │   └── request.ts
    └── app/
        ├── layout.tsx
        └── page.tsx

Bước 1: Tạo file bản dịch

// messages/en.json
{
  "HomePage": {
    "title": "Hello world!",
    "description": "Welcome to our service."
  },
  "common": {
    "save": "Save",
    "cancel": "Cancel"
  }
}
// messages/vi.json
{
  "HomePage": {
    "title": "Xin chào!",
    "description": "Chào mừng bạn đến với dịch vụ của chúng tôi."
  },
  "common": {
    "save": "Lưu",
    "cancel": "Hủy"
  }
}

Bước 2: Cấu hình next.config.ts

Đây là bước bắt buộc. Nếu không đăng ký plugin của next-intl, các tính năng type safety và tích hợp với Server Components sẽ không hoạt động đúng.

// next.config.ts
import { NextConfig } from 'next'
import createNextIntlPlugin from 'next-intl/plugin'

const nextConfig: NextConfig = {}

const withNextIntl = createNextIntlPlugin()
export default withNextIntl(nextConfig)

Nếu bạn muốn đặt file i18n/request.ts ở vị trí khác, hãy truyền đường dẫn tùy chỉnh:

const withNextIntl = createNextIntlPlugin('./somewhere/else/request.ts')

Bước 3: Cấu hình i18n/request.ts

File này cung cấp locale và messages cho phía server theo từng request.

// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server'

export default getRequestConfig(async () => {
  // Tạm thời dùng giá trị cố định, sẽ thay bằng Cookie hoặc routing sau
  const locale = 'en'

  return {
    locale,
    messages: (await import(`../../messages/${locale}.json`)).default,
  }
})

Nếu muốn chuyển đổi locale qua Cookie:

import { cookies } from 'next/headers'
import { getRequestConfig } from 'next-intl/server'

export default getRequestConfig(async () => {
  const store = await cookies()
  const locale = store.get('locale')?.value || 'en'

  return {
    locale,
    messages: (await import(`../../messages/${locale}.json`)).default,
  }
})

Bước 4: Cấu hình Layout

// src/app/layout.tsx
import { NextIntlClientProvider } from 'next-intl'

type Props = {
  children: React.ReactNode
}

export default async function RootLayout({ children }: Props) {
  return (
    <html>
      <body>
        <NextIntlClientProvider>{children}</NextIntlClientProvider>
      </body>
    </html>
  )
}

NextIntlClientProvider là Provider cho phép Client Components truy cập messages. Việc truyền messages được thực hiện tự động, nên trong các trường hợp chỉ dùng Server Components thì có thể bỏ qua — nhưng tốt nhất vẫn nên giữ để tránh lỗi sau này.


Bước 5: Sử dụng bản dịch trong trang

Trong Server Component (async):

// src/app/page.tsx
import { getTranslations } from 'next-intl/server'

export default async function HomePage() {
  const t = await getTranslations('HomePage')

  return (
    <main>
      <h1>{t('title')}</h1>
      <p>{t('description')}</p>
    </main>
  )
}

Trong Client Component (hook đồng bộ):

'use client'

import { useTranslations } from 'next-intl'

export function SaveButton() {
  const t = useTranslations('common')

  return (
    <button onClick={() => console.log('saved')}>
      {t('save')}
    </button>
  )
}

Phần 2: Cấu hình routing theo locale

Đây là cấu hình production-ready với URL phân tách theo ngôn ngữ (/en/about, /vi/about).

Cấu trúc thư mục sau khi chuyển đổi

├── messages/
│   ├── en.json
│   └── vi.json
├── next.config.ts
└── src/
    ├── i18n/
    │   ├── routing.ts      ← thêm mới
    │   ├── navigation.ts   ← thêm mới
    │   └── request.ts      ← cập nhật
    ├── proxy.ts            ← thêm mới (thay thế middleware.ts)
    └── app/
        └── [locale]/       ← segment mới
            ├── layout.tsx
            ├── page.tsx
            └── ...

Lưu ý: Từ Next.js 16 trở lên, middleware.ts được đổi tên thành proxy.ts. Nếu bạn dùng Next.js dưới v16, hãy giữ tên middleware.ts.


Bước 1: Cấu hình routing

// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing'

export const routing = defineRouting({
  locales: ['en', 'vi'],
  defaultLocale: 'en',
})

Locale được chỉ định trong defaultLocale có thể được truy cập mà không cần prefix trong URL — ví dụ /about sẽ là tiếng Anh, còn /vi/about là tiếng Việt.


Bước 2: Cấu hình proxy (middleware)

// src/proxy.ts  (Next.js < 16 thì dùng src/middleware.ts)
import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'

export default createMiddleware(routing)

export const config = {
  matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)',
}

Regex trong matcher đảm bảo các request tới API routes và file tĩnh sẽ không bị middleware xử lý.


Bước 3: Tạo navigation utilities

// src/i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation'
import { routing } from './routing'

export const { Link, redirect, usePathname, useRouter, getPathname } =
  createNavigation(routing)

Thay vì import LinkuseRouter từ next/link hay next/navigation, hãy import từ file này để locale prefix được tự động thêm vào.


Bước 4: Cập nhật request.ts

// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server'
import { hasLocale } from 'next-intl'
import { routing } from './routing'

export default getRequestConfig(async ({ requestLocale }) => {
  const requested = await requestLocale
  const locale = hasLocale(routing.locales, requested)
    ? requested
    : routing.defaultLocale

  return {
    locale,
    messages: (await import(`../../messages/${locale}.json`)).default,
  }
})

Dùng hasLocale để fallback về defaultLocale khi locale không hợp lệ được truyền vào.


Bước 5: Cấu hình layout [locale]

// src/app/[locale]/layout.tsx
import { NextIntlClientProvider, hasLocale } from 'next-intl'
import { notFound } from 'next/navigation'
import { routing } from '@/i18n/routing'

type Props = {
  children: React.ReactNode
  params: Promise<{ locale: string }>
}

export default async function LocaleLayout({ children, params }: Props) {
  const { locale } = await params

  if (!hasLocale(routing.locales, locale)) {
    notFound()
  }

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider>{children}</NextIntlClientProvider>
      </body>
    </html>
  )
}

Bật Static Rendering

Mặc định khi dùng next-intl, các route sẽ render động (dynamic rendering). Để bật static rendering, cần thêm cấu hình sau.

Thêm generateStaticParams

// Thêm vào src/app/[locale]/layout.tsx
import { routing } from '@/i18n/routing'

export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }))
}

Thêm setRequestLocale

Gọi setRequestLocale trong từng layout và page để bật static rendering.

// src/app/[locale]/layout.tsx (đầy đủ)
import { setRequestLocale } from 'next-intl/server'
import { hasLocale } from 'next-intl'
import { notFound } from 'next/navigation'
import { routing } from '@/i18n/routing'

type Props = {
  children: React.ReactNode
  params: Promise<{ locale: string }>
}

export default async function LocaleLayout({ children, params }: Props) {
  const { locale } = await params

  if (!hasLocale(routing.locales, locale)) {
    notFound()
  }

  // Bật static rendering
  setRequestLocale(locale)

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider>{children}</NextIntlClientProvider>
      </body>
    </html>
  )
}
// src/app/[locale]/page.tsx
import { use } from 'react'
import { setRequestLocale } from 'next-intl/server'
import { useTranslations } from 'next-intl'

export default function HomePage({ params }) {
  const { locale } = use(params)

  // Phải gọi setRequestLocale trước khi dùng bất kỳ API nào của next-intl
  setRequestLocale(locale)

  const t = useTranslations('HomePage')

  return <h1>{t('title')}</h1>
}

Lưu ý: setRequestLocale phải được gọi trước useTranslations và các API khác của next-intl. Vì Next.js render layout và page độc lập với nhau, nên bạn cần thiết lập ở cả hai nơi.


Các use case thực tế

Nội suy biến và số nhiều (ICU Message Format)

next-intl sử dụng ICU Message Format, cho phép xử lý biến động và số nhiều một cách khai báo.

// messages/en.json
{
  "profile": {
    "greeting": "Hello, {name}!",
    "followerCount": "You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}."
  }
}
const t = useTranslations('profile')

t('greeting', { name: 'An' })
// → "Hello, An!"

t('followerCount', { count: 0 })    // → "You have no followers yet."
t('followerCount', { count: 1 })    // → "You have one follower."
t('followerCount', { count: 3580 }) // → "You have 3,580 followers."

Ký hiệu # sẽ tự động format số theo locale (dấu phân cách hàng nghìn, v.v.). Các tag số nhiều (one, other, few, many...) khác nhau tùy ngôn ngữ — next-intl dùng Intl.PluralRules để tự động chọn tag phù hợp.


Rich text (bản dịch chứa component React)

Dùng t.rich khi muốn nhúng React component vào trong chuỗi bản dịch.

{
  "terms": "Please refer to <guidelines>the guidelines</guidelines>."
}
t.rich('terms', {
  guidelines: (chunks) => <a href="/guidelines">{chunks}</a>
})
// → <>Please refer to <a href="/guidelines">the guidelines</a>.</>

Link và navigation hỗ trợ locale

Import Link từ @/i18n/navigation để locale prefix được tự động xử lý.

import { Link } from '@/i18n/navigation'

export function Nav() {
  return (
    <nav>
      <Link href="/">Trang chủ</Link>
      <Link href="/about">Giới thiệu</Link>
      {/* Chuyển sang locale khác bằng prop locale */}
      <Link href="/about" locale="en">View in English</Link>
    </nav>
  )
}

Metadata theo từng locale

// src/app/[locale]/about/page.tsx
import { getTranslations } from 'next-intl/server'
import type { Metadata } from 'next'

type Props = {
  params: Promise<{ locale: string }>
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { locale } = await params
  const t = await getTranslations({ locale, namespace: 'about' })

  return {
    title: t('metaTitle'),
    description: t('metaDescription'),
  }
}

export default async function AboutPage() {
  const t = await getTranslations('about')
  return <h1>{t('title')}</h1>
}
// messages/vi.json — thêm namespace about
{
  "about": {
    "metaTitle": "Giới thiệu | MyApp",
    "metaDescription": "Về đội ngũ và sứ mệnh của chúng tôi",
    "title": "Giới thiệu"
  }
}

Type-safe message keys

Kết hợp với TypeScript, next-intl có thể báo lỗi compile-time khi bạn tham chiếu đến key không tồn tại.

// src/global.d.ts
import en from '../messages/en.json'

type Messages = typeof en

declare global {
  interface IntlMessages extends Messages {}
}

Sau khi thiết lập, t('keyKhongTonTai') sẽ hiển thị lỗi ngay trong editor. Lợi ích này càng rõ ràng khi file bản dịch ngày càng lớn.


Tóm tắt

Tình huống Dùng gì
Cấu hình đơn giản (không routing) Quản lý locale qua Cookie
Cấu hình routing defineRouting + proxy.ts + segment [locale]
Bản dịch trong Server Component getTranslations (async)
Bản dịch trong Client Component useTranslations (hook)
Nội suy biến & số nhiều ICU Message Format
Rich text t.rich
Link & router hỗ trợ locale createNavigation
Metadata theo locale generateMetadata + getTranslations
Static rendering generateStaticParams + setRequestLocale
Type-safe message keys Khai báo kiểu Messages trong global.d.ts

Những lỗi thường gặp

Quên cấu hình next.config.ts Nếu không đăng ký plugin, type safety sẽ không hoạt động. Hãy chắc chắn đã wrap config với createNextIntlPlugin().

Sai thứ tự gọi setRequestLocale setRequestLocale phải được gọi trước useTranslations và các API khác. Nếu gọi sai thứ tự sẽ xuất hiện cảnh báo runtime.

Nhầm middleware.tsproxy.ts Next.js 16+ dùng proxy.ts. Nếu bạn đang dùng Next.js dưới v16, hãy giữ tên middleware.ts.

Dùng Link từ next/link thay vì từ @/i18n/navigation Link của Next.js không tự thêm locale prefix. Luôn import Link từ file navigation bạn đã tạo bằng createNavigation.


Kết luận

next-intl được thiết kế dành riêng cho App Router, hoạt động tự nhiên với cả Server Components lẫn Client Components. Bước setup ban đầu hơi tốn công, nhưng một khi đã hoàn thiện, bạn chỉ cần thêm file bản dịch là có thể mở rộng sang ngôn ngữ mới.

Tích hợp đa ngôn ngữ về sau sẽ tốn rất nhiều công sức refactor. Nếu dự án của bạn có bất kỳ khả năng nào cần hỗ trợ nhiều ngôn ngữ, hãy thiết lập i18n từ sớm — chi phí bỏ ra ban đầu sẽ xứng đáng.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.