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
- Node.js (>= 10.13)
- 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'34const LocaleProvider = ({ lang, children }) => {5 const [locale, setLocale] = useState(lang)6 const { query } = useRouter()78 // Sync context with router9 useEffect(() => {10 if (LOCALES.includes(query.lang) && locale !== query.lang) {11 setLocale(query.lang)12 }13 }, [query.lang, locale])1415 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'34export default class MyDocument extends Document {5 static async getInitialProps(ctx) {6 const sheet = new ServerStyleSheet()7 const originalRenderPage = ctx.renderPage8 const { query } = ctx910 try {11 ctx.renderPage = () => originalRenderPage({12 enhanceApp: (App) => (props) => sheet.collectStyles(13 <App {...props} />)14 }15 )1617 const initialProps = await Document.getInitialProps(ctx)1819 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 }3334 render() {35 return (36 <Html lang={this.props.lang || DEFAULT_LOCALE}>37 <Head />3839 <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'23export default class MyApp extends App {4 render() {5 const { Component, pageProps } = this.props67 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'34/**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)1415 let i18nContent = {}1617 if (i18nFiles.includes(i18nSlug)) {18 i18nContent = { [namespace]: JSON.parse(fs.readFileSync(i18nPath, 'utf8'))[0] }19 }2021 return i18nContent22}
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_LOCALE34 const translations = getI18n(lang, 'home')56 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'23export const useLocale = (translations) => {4 const { locale } = useContext(LocaleContext)5 const i18nCommon = locale ? common[locale] : {}6 const i18nScoped = translations || {}78 const i18nContent = {9 common: i18nCommon,10 ...i18nScoped,11 }1213 const t = (key) => {14 const kArray = key.split('.')1516 // Parsing possibly nested object17 let res = i18nContent1819 kArray.forEach((k) => {20 res = typeof res === 'string' ? res : res[k]21 })2223 return typeof res === 'string' ? res : key24 }2526 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'23//...45export async function getStaticPaths() {6 return {7 paths: LOCALES.map((lang) => (8 {9 params: { lang }10 }11 )),12 fallback: false13 }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'23import { useLocale } from '@i18n/context'4import { DEFAULT_LOCALE } from '@i18n/constants'5import { getInitialLocale } from '@i18n/utils'67export default function Index({ translations }) {8 const { t } = useLocale(translations)9 const router = useRouter()1011 useEffect(() => {12 router.replace('/[lang]', `/${getInitialLocale()}`)13 })1415 return (16 <Head>17 // Here you should put your SEO meta tags...1819 <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... 🤞