Next.js (SSG) & i18n

Publié il y a plus d’un an~6 min

L'ajout du support multilingue (i18n) pour un site ou une application web est un véritable enjeu, car cela soulève très souvent des problématiques importantes telles que la structure de la donnée, le routing .. Il existe certes des solutions, mais trop souvent celles-ci s'avèrent trop rigides et/ou complexes à mettre en place, en particulier dans un contexte de site statique.

Pourtant cette complexité est très souvent superflue : graĉe à React Context et aux routes dynamiques de Next.js, l'ajout du support multilingue peut s'avérer bien plus simple qu'il n'y parait !

Sur la base d'un projet Next.js, nous allons voir comment mettre en place simplement un support multilingue, grâce à React Context et aux routes dynamiques !

Pré-requis

  1. Node.js (>= 10.13)
  2. Un projet Next.js (existant ou généré via npx create-next-app)

Ce site web a servi de base à cet article, n'hésitez donc pas à vous référer au code source pour plus de détails !

Localisation

Pour partager la localisation entre les pages et les composants, nous allons utiliser React Context, parfaitement adapté ici :

1import LocaleContext from './LocaleContext'
2import { LOCALES, DEFAULT_LOCALE } from '../constants'
3
4const LocaleProvider = ({ lang, children }) => {
5 const [locale, setLocale] = useState(lang)
6 const { query } = useRouter()
7
8 // Sync context with router
9 useEffect(() => {
10 if (LOCALES.includes(query.lang) && locale !== query.lang) {
11 setLocale(query.lang)
12 }
13 }, [query.lang, locale])
14
15 return (
16 <LocaleContext.Provider value={{ locale }}>
17 {children}
18 </LocaleContext.Provider>
19 )
20}

A noter que pour s'assurer que le routing reste bien cohérent avec le contexte, on effectuera une vérification entre les deux, pour les garder synchronisés.

Pour la suite, il faudra aussi tenir compte de quelques spécificités du framework, en modifiant légèrement le fichier _document.js pour qu'il récupère bien la query et la transmette bien par la suite, en particulier pour l'attribut lang de notre page HTML :

1import { ServerStyleSheet } from 'styled-components'
2import { DEFAULT_LOCALE } from '@i18n/constants'
3
4export default class MyDocument extends Document {
5 static async getInitialProps(ctx) {
6 const sheet = new ServerStyleSheet()
7 const originalRenderPage = ctx.renderPage
8 const { query } = ctx
9
10 try {
11 ctx.renderPage = () => originalRenderPage({
12 enhanceApp: (App) => (props) => sheet.collectStyles(
13 <App {...props} />)
14 }
15 )
16
17 const initialProps = await Document.getInitialProps(ctx)
18
19 return {
20 ...initialProps,
21 lang: query?.lang,
22 styles: (
23 <>
24 {initialProps.styles}
25 {sheet.getStyleElement()}
26 </>
27 )
28 }
29 } finally {
30 sheet.seal()
31 }
32 }
33
34 render() {
35 return (
36 <Html lang={this.props.lang || DEFAULT_LOCALE}>
37 <Head />
38
39 <body>
40 <Main />
41 <NextScript />
42 </body>
43 </Html>
44 )
45 }
46}

Pour finir, il suffira de déclarer le <LocaleProvider /> dans _app.js, en lui passant les props du document comme valeur de langue :

1import { LocaleProvider } from '@i18n/context'
2
3export default class MyApp extends App {
4 render() {
5 const { Component, pageProps } = this.props
6
7 return (
8 <LocaleProvider lang={pageProps.lang}>
9 <Component {...pageProps} />
10 </LocaleProvider>
11 )
12 }
13}

Le contexte React est désormais en place, et correctement connecté avec l'ensemble de l'applicatif ! 🔌

Traductions

Les traductions seront créées sous la forme de fichiers JSON : des fichiers pour les traductions communes, et d'autres par page. On adoptera la convention de nommage suivante : <lang>.json et <pagename>.<lang>.json.

Pour récupérer nos traductions dans les pages, nous allons créer une simple fonction qui ira chercher dans nos fichiers le bloc de traductions souhaitées, selon le namespace et la langue déclarés :

1import fs from 'fs'
2import { join } from 'path'
3
4/**
5 * getI18n()
6 * @params: lang (required), namespace (required)
7 * @returns: Array of object(s)
8 */
9export const getI18n = (lang, namespace) => {
10 const i18nDirectory = join(process.cwd(), 'src/data/i18n')
11 const i18nFiles = fs.readdirSync(i18nDirectory)
12 const i18nSlug = `${namespace}.${lang}.json`
13 const i18nPath = join(i18nDirectory, i18nSlug)
14
15 let i18nContent = {}
16
17 if (i18nFiles.includes(i18nSlug)) {
18 i18nContent = { [namespace]: JSON.parse(fs.readFileSync(i18nPath, 'utf8'))[0] }
19 }
20
21 return i18nContent
22}

Ces traductions seront par la suite récupérées via la méthode getStaticProps() mise à disposition par Next.js :

1export async function getStaticProps({ params }) {
2 const lang = params?.lang || DEFAULT_LOCALE
3
4 const translations = getI18n(lang, 'home')
5
6 return { props: { translations } }
7}

Il faut désormais pouvoir consommer ce bloc de traductions... Pour cela, rien de plus simple qu'un hook, s'appuyant sur le contexte React précédemment mis en place :

1import * as common from '@data/i18n/common'
2
3export const useLocale = (translations) => {
4 const { locale } = useContext(LocaleContext)
5 const i18nCommon = locale ? common[locale] : {}
6 const i18nScoped = translations || {}
7
8 const i18nContent = {
9 common: i18nCommon,
10 ...i18nScoped,
11 }
12
13 const t = (key) => {
14 const kArray = key.split('.')
15
16 // Parsing possibly nested object
17 let res = i18nContent
18
19 kArray.forEach((k) => {
20 res = typeof res === 'string' ? res : res[k]
21 })
22
23 return typeof res === 'string' ? res : key
24 }
25
26 return { t, lang: locale }
27}

Ce hook retournera donc la langue, mais aussi une fonction t() permettant de récupérer une traduction sur la base d'une clé passée en argument.

Les données de traductions seront par défaut les traductions communes (importées directement), et le cas échéant les traductions de la page.

A noter qu'en cas d'indisponibilité de la traduction, t() retournera simplement la clé qui lui aura été passée, sous la forme d'une chaîne de caractères.

Localisation des pages et routing

Nos données sont maintenant prêtes à être exploitées au sein de notre projet. Il reste maintenant à localiser nos pages à proprement parler, et c'est là qu'intervient le routing dynamique de Next.js !

Pour commencer, nous allons donc déplacer l'ensemble de nos pages dans un dossier [lang]. Ce faisant, nous indiquons simplement à Next.js que nos pages dépendront d'un paramètre de query nommé lang, qui sera interprété ensuite au niveau du _document.js, modifié en ce sens.

Construction des chemins de pages

Puisque l'on utilise des routes dynamiques, nous devons définir les chemins pour chacune de nos pages, afin de les rendre accessibles au routeur. Nous utilisons ici la fonction getStaticPaths, là aussi spécifique à la génération statique :

1import { LOCALES } from '@i18n/constants'
2
3//...
4
5export async function getStaticPaths() {
6 return {
7 paths: LOCALES.map((lang) => (
8 {
9 params: { lang }
10 }
11 )),
12 fallback: false
13 }
14}

Rien de bien compliqué ici : on retourne les chemins pour l'ensemble des langues supportées, déclarées dans la constante LOCALES.

Routing

Toutes nos urls de pages sont désormais préfixées par un /[lang], et à ce stade tout fonctionne comme il se doit... Mais que se passe-t-il si l'on omet le paramètre de langue dans notre url ?... Une 404 !!! 😱

Pour éviter cela, mais aussi pour améliorer notre SEO, nous allons donc créer un fichier index.js à la racine de notre dossier pages :

1import { getI18n } from '@api/i18n'
2
3import { useLocale } from '@i18n/context'
4import { DEFAULT_LOCALE } from '@i18n/constants'
5import { getInitialLocale } from '@i18n/utils'
6
7export default function Index({ translations }) {
8 const { t } = useLocale(translations)
9 const router = useRouter()
10
11 useEffect(() => {
12 router.replace('/[lang]', `/${getInitialLocale()}`)
13 })
14
15 return (
16 <Head>
17 // Here you should put your SEO meta tags...
18
19 <meta key="robots" name="robots" content="noindex, nofollow" />
20 </Head>
21 )
22}

Concrètement, ce fichier se chargera simplement de rediriger l'utilisateur vers la page correspondant à la langue du document HTML (et du contexte). Et pour éviter que cette page sans contenu ne soit indexée par les moteurs de recherches, on ajoute une <meta name="robots" /> adaptée.

La redirection se fait ici via le routeur Next.js, côté client (cf. useEffect). Cela permettra à la redirection d'être quasi imperceptible pour l'utilisateur !

🎉

Et voilà ! Malgré les nombreuses modifications de fichiers, la logique reste au final assez simple, nous permettant ainsi de construire un projet multilingue souple et adaptable au besoin. Il ne restera plus ensuite qu'à peaufiner le code à l'envie, en ajoutant par exemple un sélecteur de langue, une gestion des erreurs (en cas de traduction manquante sur une page), ... 🙂


NB: Un grand merci à @BiscuiTech et @filipcodes pour leur travail qui m'a grandement inspiré (ici et ici). Et si le sujet vous intéresse, n'hésitez pas non plus à suivre les discussions sur le dépôt officiel Next.js ! Le routing internationalisé annoncé en version 10 ne supporte malheureusement pas encore l'export statique, mais on croise les doigts... 🤞