Next.js11 min read
FR/EN internationalization
Our apps speak French and English. No text is hard-coded: everything lives in JSON files, one per language and per screen, loaded by next-intl.
How a page finds its language
The locale is in the URL (/fr/…, /en/…). The proxy detects it, request.ts loads the right JSON, and each component reads its text by key. Change the language or the invoice count to watch plurals and formats adapt.
1URL
/fr/invoices2proxy.ts
locale = "fr"3messages/fr|en
{ "invoices": { "title": "Factures", "overdue": "{count, plural, =0 {Aucune f…" } }4Component
t('title')
Mis à jour le 16 septembre 2026
Factures
Bonjour Aina, voici le point du jour.
Setup
Five files, once per project. Copy them as they are.
- 1
Install next-intl
terminalnpm install next-intl - 2
Declare the locales
src/i18n/routing.tsimport { defineRouting } from 'next-intl/routing'; export const routing = defineRouting({ locales: ['fr', 'en'], defaultLocale: 'fr', localePrefix: 'always', // /fr/invoices, /en/invoices }); - 3
Load the JSON for the requested locale
src/i18n/request.tsimport { hasLocale } from 'next-intl'; import { getRequestConfig } from 'next-intl/server'; import { routing } from './routing'; const namespaces = ['common', 'auth', 'invoices', 'validation'] as const; export default getRequestConfig(async ({ requestLocale }) => { const requested = await requestLocale; const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale; // One JSON file per screen: messages/fr/invoices.json -> t('invoices.title') const entries = await Promise.all( namespaces.map(async (ns) => [ns, (await import(`../../messages/${locale}/${ns}.json`)).default] as const), ); return { locale, messages: Object.fromEntries(entries), timeZone: 'Europe/Paris', }; }); - 4
Wire the proxy, the plugin and navigation
src/proxy.tsimport createMiddleware from 'next-intl/middleware'; import { routing } from './i18n/routing'; export default createMiddleware(routing); export const config = { // Everything except API routes, Next.js internals and static files matcher: '/((?!api|_next|_vercel|.*\\..*).*)', };next.config.tsimport type { NextConfig } from 'next'; import createNextIntlPlugin from 'next-intl/plugin'; const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); const nextConfig: NextConfig = {}; export default withNextIntl(nextConfig);src/i18n/navigation.tsimport { createNavigation } from 'next-intl/navigation'; import { routing } from './routing'; // Use these instead of next/link and next/navigation: they keep the locale export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing); - 5
Validate the locale in the layout
src/app/[locale]/layout.tsximport { notFound } from 'next/navigation'; import { hasLocale, NextIntlClientProvider } from 'next-intl'; import { setRequestLocale } from 'next-intl/server'; import { routing } from '@/i18n/routing'; export function generateStaticParams() { return routing.locales.map((locale) => ({ locale })); } export default async function LocaleLayout({ children, params }: LayoutProps<'/[locale]'>) { const { locale } = await params; if (!hasLocale(routing.locales, locale)) notFound(); setRequestLocale(locale); // enables static rendering return ( <html lang={locale}> <body> <NextIntlClientProvider>{children}</NextIntlClientProvider> </body> </html> ); }
Organizing JSON files
One file per screen or domain (invoices.json), one folder per language. Keys are identical in fr and en; only values change.
{
"title": "Factures",
"empty": {
"title": "Aucune facture pour l'instant",
"action": "Créer une facture"
},
"overdue": "{count, plural, =0 {Aucune facture en retard} one {# facture en retard} other {# factures en retard}}",
"total": "Total : {amount, number, ::currency/EUR}",
"updated": "Mis à jour le {date, date, long}",
"terms": "J'accepte les <link>conditions générales</link>",
"status": {
"draft": "Brouillon",
"sent": "Envoyée",
"paid": "Payée"
}
}{
"title": "Invoices",
"empty": {
"title": "No invoices yet",
"action": "Create an invoice"
},
"overdue": "{count, plural, =0 {No overdue invoices} one {# overdue invoice} other {# overdue invoices}}",
"total": "Total: {amount, number, ::currency/EUR}",
"updated": "Updated on {date, date, long}",
"terms": "I accept the <link>terms and conditions</link>",
"status": {
"draft": "Draft",
"sent": "Sent",
"paid": "Paid"
}
}- camelCase keys, nested by screen area:
empty.title,table.columns.total. - Plurals, numbers and dates in ICU inside the value, never computed in the component.
- Rich tags (link, bold) declared in the text and rendered with
t.rich. - common.json for shared text: buttons, generic errors, navigation.
Using translations
In a Server Component: await getTranslations(). In a Client Component: useTranslations(). Dates and numbers go through useFormatter.
import { getTranslations } from 'next-intl/server';
export async function generateMetadata({ params }: PageProps<'/[locale]/invoices'>) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'invoices' });
return { title: t('title') };
}
export default async function InvoicesPage() {
const t = await getTranslations('invoices');
const overdueCount = await countOverdueInvoices();
return (
<header>
<h1>{t('title')}</h1>
<p>{t('overdue', { count: overdueCount })}</p>
</header>
);
}'use client';
import { useFormatter, useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
export function InvoiceSummary({ total, updatedAt, status }: Props) {
const t = useTranslations('invoices');
const format = useFormatter();
return (
<div>
<Badge>{t(`status.${status}`)}</Badge>
<p>{t('total', { amount: total / 100 })}</p>
<time dateTime={updatedAt.toISOString()}>{format.relativeTime(updatedAt)}</time>
<label>
<input type="checkbox" />
{t.rich('terms', {
link: (chunks) => <Link href="/terms">{chunks}</Link>,
})}
</label>
</div>
);
}Avoid
<p>{t('you_have')} {count} {count > 1 ? t('invoices') : t('invoice')}</p>
<button>Enregistrer</button>
<p>{date.toLocaleDateString()}</p>Do
<p>{t('overdue', { count })}</p>
<button>{t('actions.save')}</button>
<p>{format.dateTime(date, { dateStyle: 'long' })}</p>The language switcher
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useTransition } from 'react';
import { usePathname, useRouter } from '@/i18n/navigation';
import { routing } from '@/i18n/routing';
export function LocaleSwitcher() {
const t = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const [pending, startTransition] = useTransition();
return (
<div role="group" aria-label={t('language')} aria-busy={pending}>
{routing.locales.map((l) => (
<button
key={l}
type="button"
aria-pressed={l === locale}
onClick={() => startTransition(() => router.replace(pathname, { locale: l }))}
>
{l.toUpperCase()}
</button>
))}
</div>
);
}Typed and checked keys
Message types are declared from the fr files: a misspelled key becomes a TypeScript error, with autocomplete in the editor.
// src/global.d.ts
import type { routing } from '@/i18n/routing';
import type common from '../messages/fr/common.json';
import type invoices from '../messages/fr/invoices.json';
declare module 'next-intl' {
interface AppConfig {
Locale: (typeof routing.locales)[number];
Messages: { common: typeof common; invoices: typeof invoices };
}
}
// t('invoices.titel') -> TypeScript error: the key doesn't existA CI script checks that fr and en have exactly the same keys. It blocks the PR when a translation is missing.
// scripts/check-i18n.mjs (this site uses it too), run in CI: npm run i18n:check
const flatten = (obj, prefix = '') =>
Object.entries(obj).flatMap(([key, value]) =>
value && typeof value === 'object' ? flatten(value, `${prefix}${key}.`) : [`${prefix}${key}`],
);
for (const file of readdirSync('messages/fr')) {
const fr = new Set(flatten(load('fr', file)));
const en = new Set(flatten(load('en', file)));
for (const k of fr) if (!en.has(k)) fail(`en/${file} is missing "${k}"`);
for (const k of en) if (!fr.has(k)) fail(`fr/${file} is missing "${k}"`);
}