Aller au contenu

Internationalisation avec next-intl

Pourquoi next-intl ?

Je vous ai montré dans la première partie du cours que pour rendre multilingue une application React, le module react-intl est très pratique. Cependant, react-intl n'est pas idéal dans l'environnement Next.JS. C'est pour ça que je vous parle maintenant de son équivalent, next-intl.

Installation

console
npm install next-intl

Structure du projet

Voici la structure de fichiers à mettre en place :

├── messages/
│   ├── fr.json         
│   └── en.json          
├── i18n/
│   ├── routing.ts   
│   └── request.ts   
├── proxy.ts     
├── app/
│   └── [locale]/    
│       ├── layout.tsx
│       └── page.tsx

Étapes de configuration

1. Créer les fichiers de traductions

messages/fr.json
{
  "app": {
    "titre": "Mon application",
    "bienvenue": "Bonjour, {nom} !",
    "clics": "{nombreclics, plural, =0 {Aucun clic} one {Un clic} other {{nombreclics} clics}}"
  }
}
messages/en.json
{
  "app": {
    "titre": "My application",
    "bienvenue": "Hello, {nom}!",
    "clics": "{nombreclics, plural, =0 {No clicks} one {One click} other {{nombreclics} clicks}}"
  }
}

Pensez à la structure JSON du fichier de message comme étant la structure de l'application. Par exemple, si vous avez une composante qui liste les billets vous pourrez avoir ceci dans votre fichier de traduction :

messages/fr.json
{
  "listeBillets": {
    "numero": "Numéro du billet",
    "desc": "Description",
    "demandeur": "Demandeur"
  }
}

2. Configurer le routage i18n

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

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

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

3. Configurer le chargement des messages

i18n/request.ts
import * as rootParams from 'next/root-params';
import {notFound} from 'next/navigation';
import {getRequestConfig} from 'next-intl/server';
import {hasLocale} from 'next-intl';
import {routing} from './routing';

export default getRequestConfig(async ({locale}) => {
  if (!locale) {
    const paramValue = await rootParams.locale();
    if (hasLocale(routing.locales, paramValue)) {
      locale = paramValue;
    } else {
      notFound();
    }
  }

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

4. Configurer le proxy

Le proxy détecte la langue préférée de l'utilisateur et redirige automatiquement vers la bonne URL :

/proxy.ts
import createMiddleware from 'next-intl/middleware';
import {routing} from './i18n/routing';

export default createMiddleware(routing);

export const config = {
  // Match all pathnames except for
  // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel`
  // - … the ones containing a dot (e.g. `favicon.ico`)
  matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)'
};

5. Configurer next.config.ts

next.config.ts
const withNextIntl = createNextIntlPlugin();
export default withNextIntl(nextConfig);

6. Déplacer les pages sous [locale]

Toutes vos pages doivent être dans app/[locale]/ :

src/app/[locale]/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { locale } from "next/root-params";
import "../globals.css";
import { NextIntlClientProvider } from "next-intl";
import { routing } from "@/i18n/routing";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

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

export default async function RootLayout({ children }: LayoutProps<"/[locale]">) {
  return (
    <html
      lang={await locale()}
      className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
    >
      <body className="min-h-full flex flex-col"><NextIntlClientProvider>{children}</NextIntlClientProvider></body>
    </html>
  );
}

Utilisation dans les composants

Dans un Server Component

src/app/[locale]/page.tsx
import { useTranslations } from 'next-intl';

export default function Page() {
  const t = useTranslations();

  return <h1>{t('app.titre')}</h1>;
}

Dans un Client Component

src/components/MonComposant.tsx
'use client';

import { useTranslations } from 'next-intl';

export default function MonComposant() {
  const t = useTranslations();

  return <p>{t('app.titre')}</p>;
}

Message avec variable

messages/fr.json
{
  "app" : {
   "bienvenue": "Bonjour, {nom} !"
  }
}
const t = useTranslations();

<p>{t('app.bienvenue', { nom: 'Alice' })}</p>

Message avec pluriel

messages/fr.json
{
  "app":{
     "clics": "{nombreclics, plural, =0 {Aucun clic} one {Un clic} other {{nombreclics} clics}}"
  }
}
const t = useTranslations();

<p>{t('app.clics', { nombreclics: count })}</p>

Formater une date

import { useFormatter } from 'next-intl';

const format = useFormatter();
const maintenant = new Date();

<p>{format.dateTime(maintenant, { year: 'numeric', month: 'long', day: '2-digit' })}</p>

Formater une devise (monétaire)

import { useFormatter } from 'next-intl';

const format = useFormatter();
const prix = 12.35;

<p>{format.number(prix, { style: 'currency', currency: 'CAD' })}</p>

Formater un pourcentage

import { useFormatter } from 'next-intl';

const format = useFormatter();
const rabais = 0.15;

<p>{format.number(rabais, { style: 'percent' })}</p>

Sélecteur de langue

Pour changer de langue, next-intl fournit des composants de navigation qui préservent le chemin courant tout en changeant le segment [locale] dans l'URL :

/components/SelecteurLangue.tsx
'use client';

import {useLocale} from 'next-intl';
import {useTransition} from 'react';
import {usePathname, useRouter} from '@/i18n/navigation';
import {routing} from '@/i18n/routing';

const labels: Record<string, string> = {
  fr: 'Français',
  en: 'English',
};

export default function LocaleSwitcher() {
  const locale = useLocale();
  const router = useRouter();
  const pathname = usePathname();
  const [isPending, startTransition] = useTransition();

  return (
    <select
      aria-label="Language"
      value={locale}
      disabled={isPending}
      onChange={(e) => {
        const nextLocale = e.target.value;
        startTransition(() => {
          router.replace(pathname, {locale: nextLocale});
        });
      }}
      className="rounded border border-zinc-300 bg-white px-2 py-1 text-sm dark:border-zinc-700 dark:bg-zinc-900"
    >
      {routing.locales.map((l) => (
        <option key={l} value={l}>
          {labels[l] ?? l}
        </option>
      ))}
    </select>
  );
}