Skip to content
Lantorian

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.

Overdue invoices2
  1. 1URL

    /fr/invoices
  2. 2proxy.ts

    locale = "fr"
  3. 3messages/fr|en

    {
      "invoices": {
        "title": "Factures",
        "overdue": "{count, plural, =0 {Aucune f…"
      }
    }
  4. 4Component

    t('title')

Mis à jour le 16 septembre 2026

Factures

Bonjour Aina, voici le point du jour.

2 factures en retardMontant dû : 2 499,00 €
This preview really runs next-intl on two JSON files from this site. Plurals, dates and amounts follow each language's rules.

Setup

Five files, once per project. Copy them as they are.

  1. 1

    Install next-intl

    terminal
    npm install next-intl
  2. 2

    Declare the locales

    src/i18n/routing.ts
    import { defineRouting } from 'next-intl/routing';
    
    export const routing = defineRouting({
      locales: ['fr', 'en'],
      defaultLocale: 'fr',
      localePrefix: 'always', // /fr/invoices, /en/invoices
    });
  3. 3

    Load the JSON for the requested locale

    src/i18n/request.ts
    import { 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. 4

    Wire the proxy, the plugin and navigation

    src/proxy.ts
    import 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|.*\\..*).*)',
    };
  5. 5

    Validate the locale in the layout

    src/app/[locale]/layout.tsx
    import { 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.

messages/fr/invoices.json
{
  "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"
  }
}
  • 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.

src/app/[locale]/(app)/invoices/page.tsx
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>
  );
}

Avoid

invoice-header.tsx
<p>{t('you_have')} {count} {count > 1 ? t('invoices') : t('invoice')}</p>

<button>Enregistrer</button>

<p>{date.toLocaleDateString()}</p>

Do

invoice-header.tsx
<p>{t('overdue', { count })}</p>

<button>{t('actions.save')}</button>

<p>{format.dateTime(date, { dateStyle: 'long' })}</p>

The language switcher

src/components/layout/locale-switcher.tsx
'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
// 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 exist

A 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
// 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}"`);
}