best-i18n
Integrations

Next.js

best-i18n/next — App Router, Turbopack or webpack

Next.js does not run on Vite, so it gets its own loader and its own way of carrying the locale through a render.

// src/i18n.ts - one description of the languages and the URL shape
import { defineI18nConfig } from 'best-i18n/next/config'

export const i18n = defineI18nConfig({
  locales: ['en', 'zh'],
  baseLocale: 'en',
  exclude: '^/(api|_next)/',
})
// next.config.ts - the same description, imported rather than repeated
import process from 'node:process'
import { createI18nPlugin } from 'best-i18n/next'
import { i18n } from './src/i18n'

const withI18n = createI18nPlugin({
  ...i18n,
  messagesDir: fileURLToPath(new URL('./messages', import.meta.url)),
  staticLocale: process.env.I18N_STATIC_LOCALE,
})

export default withI18n({})

The locales are spread in rather than repeated: the compiler needs them at build time, and src/i18n.ts is where they are described once.

// src/proxy.ts - points a public URL at the [locale] segment
import { createProxy } from 'best-i18n/next/proxy'
import { i18n } from '@/i18n'

export const proxy = createProxy(i18n)
export const config = { matcher: ['/((?!_next|.*\\..*).*)'] }
// src/app/[locale]/layout.tsx
import { getLocale } from 'best-i18n/next/server'
import { LocaleProvider } from 'best-i18n/react'
import { i18n } from '@/i18n'

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

export const dynamicParams = false

export default function LocaleLayout({ children }) {
  const locale = getLocale()

  return (
    <html lang={locale}>
      <body>
        <LocaleProvider locale={locale} config={i18n}>
          {children}
        </LocaleProvider>
      </body>
    </html>
  )
}

That is the whole setup. t then works in any Server Component — no per-file call, no await, static rendering intact — and useI18n in any Client Component.

URLs and navigation

Routes live under [locale], but the base locale's URLs stay unprefixed: /about is English, /zh/about is Chinese, and /en/about redirects to the canonical /about. Links are written unprefixed and localized as they render:

import { Link } from 'best-i18n/next/navigation'

// Renders href="/zh/about" while Chinese is active.
function Nav() {
  return <Link href='/about'>{t`About`}</Link>
}

usePathname and useRouter come from the same module, with the prefix stripped and applied respectively. All three read the URL layout from LocaleProvider, so the config is described once and travels once — and they are exported one by one, so an app that only links does not carry the other two.

The unprefixed base locale assumes a proxy is there to rewrite /about onto the [locale] segment. A deployment without one — a static export serves only the files that exist — sets prefixBase: true in the config instead: /en/about becomes the canonical form, Link prefixes the base locale like any other, and a proxy (if one runs anyway) redirects unprefixed URLs out rather than stripping /en. This site is exactly that case: its "Get started" button is a Link href='/docs' compiled by best-i18n itself.

Why the pieces are what they are

  • getLocale() in the root layout, once. It is what pulls in the module that teaches the runtime where Next keeps the locale. Skip it and every message quietly renders in the base locale.
  • LocaleProvider even though the server already knows the locale. Client components render in a second module graph that cannot see the server's render state; passing the locale through React is what keeps the server's HTML and the first client render identical. Its config is required for the same reason: it is the channel Link and usePathname read the URL layout from, so keep it serializable (exclude as a string).
  • Server Components need no per-file setup. A layout and the page beneath it are separate renders in the App Router, so a locale stashed in one is not visible in the other. The locale is read from the route param instead, which is also why static rendering still works.

Route handlers

A statically prerendered route handler (dynamic = 'force-static' with generateStaticParams) runs outside the React render, where the route params are not visible to t. Bind the locale explicitly at the top of the handler:

// src/app/[locale]/greeting/route.ts
import { setRequestLocale } from 'best-i18n/next/server'

export async function GET(
  request: Request,
  { params }: { params: Promise<{ locale: string }> },
) {
  const { locale } = await params
  setRequestLocale(locale)
  // every t after this resolves in `locale`
}

setRequestLocale binds the current async context, so concurrent requests stay isolated. Inside a render it is the same call you would use to opt a page into static rendering.

On this page