Internacionalización en Next.js con React-i18next [REPO]

En el tutorial de hoy vamos a ver cómo configurar Next.js para mostrar tu contenido en varios idiomas para apoyar el proceso de internacionalización de tu producto.

Puedes ver el código en github y el proyecto desplegado en codesandbox:

https://codesandbox.io/p/github/juannunezblasco/nextjs-localized-i18n/main

https://github.com/juannunezblasco/nextjs-localized-i18n

Para llevar a cabo la implementación, voy a usar tres bibliotecas que me parecen geniales:

  • react-i18next: Esta es una solución súper potente para la internacionalización de aplicaciones React y Next.js. Me permite traducir mi aplicación a varios idiomas de manera sencilla y me proporciona componentes React fáciles de usar. Lo mejor de todo es que es muy flexible y funciona tanto en el servidor como en el cliente, lo que la hace perfecta para mis proyectos en Next.js.
  • i18next-resources-to-backend: Esta biblioteca me permite cargar y guardar las traducciones en el servidor backend. Esto es muy útil, especialmente en proyectos grandes donde necesito gestionar las traducciones de forma centralizada y cargarlas dinámicamente según sea necesario. Además, me ayuda a mejorar el rendimiento al reducir la cantidad de datos que necesito cargar en el cliente.
  • i18next-browser-languagedetector: Esta es una extensión para i18next que detecta el idioma del usuario en el navegador. Es genial porque me permite configurar automáticamente el idioma de la aplicación en función de la configuración del idioma del usuario. La biblioteca admite la detección de idioma a través de varios métodos, incluyendo el idioma del navegador, las cookies, la URL, etc.

Configuración global i18n

// i18n/index.ts
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { initReactI18next } from 'react-i18next/initReactI18next'
import { getOptions } from './settings'

export async function useTranslation(lng: string, ns?: string, options: { keyPrefix?: string } = {}) {
  const i18nextInstance = await initI18next(lng, ns)
  return {
    t: i18nextInstance.getFixedT(lng, Array.isArray(ns) ? ns[0] : ns, options.keyPrefix),
    i18n: i18nextInstance
  }
}

const initI18next = async (lng: string, ns?: string) => {
  const i18nInstance = createInstance()
  await i18nInstance
    .use(initReactI18next)
    .use(resourcesToBackend((language: string, namespace: string) => import(`./locales/${language}/${namespace}.json`)))
    .init(getOptions(lng, ns))
  return i18nInstance
}

El bloque de código anterior muestra cómo configurar la internacionalización en Next.js utilizando la biblioteca i18next. Se define una función useTranslation que inicializa una instancia de i18next con el idioma y el namespace especificados. Esta función retorna un objeto con una función de traducción t y la instancia de i18next.

La función initI18next se encarga de crear e inicializar la instancia de i18next, usando el idioma y el namespace proporcionados. Esta función utiliza initReactI18next para integrar i18next con React y resourcesToBackend para cargar las traducciones desde el servidor backend. Las opciones para la inicialización de i18next se obtienen de la función getOptions.

// i18n/settings.ts 
	
export const fallbackLng: string = 'es'
export const languages: string[] = [fallbackLng, 'en']
export const cookieName: string = 'i18next'
export const defaultNS: string = 'translation'

export function getOptions (lng = fallbackLng, ns = defaultNS) {
  return {
    // debug: true,
    supportedLngs: languages,
    fallbackLng,
    lng,
    fallbackNS: defaultNS,
    defaultNS,
    ns
  }
}

En el código de arriba, configuré i18n. Definí un par de constantes: fallbackLng, que es el idioma que usamos por defecto, y languages, un array con todos los idiomas que manejamos. Creo que sería más flexible si tomamos estos valores de las variables de entorno.

También definí cookieName, que es cómo llamamos a la cookie donde guardamos el idioma seleccionado, y defaultNS, que es nuestro namespace por defecto. Al final, tengo una función getOptions que devuelve un objeto con todas las opciones que usaremos para inicializar i18n.

Solo un apunte: normalmente, en un entorno de producción, definiríamos estas constantes como variables de entorno. De esta forma, podemos tener más control y seguridad, ya que pueden variar según el entorno y no estarían a la vista en el código. Pero, para que sea más fácil de entender, las he dejado escritas directamente en el código.

// i18n/client.ts

'use client'

import { useEffect, useState } from 'react'
import i18next from 'i18next'
import { initReactI18next, useTranslation as useTranslationOrg } from 'react-i18next'
import { useCookies } from 'react-cookie'
import resourcesToBackend from 'i18next-resources-to-backend'
import LanguageDetector from 'i18next-browser-languagedetector'
import { getOptions, languages, cookieName } from './settings'

const runsOnServerSide = typeof window === 'undefined'

// 
i18next
  .use(initReactI18next)
  .use(LanguageDetector)
  .use(resourcesToBackend((language: string, namespace: string) => import(`./locales/${language}/${namespace}.json`)))
  .init({
    ...getOptions(),
    lng: undefined, // let detect the language on client side
    detection: {
      order: ['path', 'htmlTag', 'cookie', 'navigator'],
    },
    preload: runsOnServerSide ? languages : []
  })



export function useTranslation(lng: string, ns?: string, options: any = {}) {
  const [cookies, setCookie] = useCookies([cookieName])
  const ret = useTranslationOrg(ns, options)
  const { i18n } = ret
  if (runsOnServerSide && lng && i18n.resolvedLanguage !== lng) {
    i18n.changeLanguage(lng)
  } else {
    // eslint-disable-next-line react-hooks/rules-of-hooks
    const [activeLng, setActiveLng] = useState(i18n.resolvedLanguage)
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useEffect(() => {
      if (activeLng === i18n.resolvedLanguage) return
      setActiveLng(i18n.resolvedLanguage)
    }, [activeLng, i18n.resolvedLanguage])
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useEffect(() => {
      if (!lng || i18n.resolvedLanguage === lng) return
      i18n.changeLanguage(lng)
    }, [lng, i18n])
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useEffect(() => {
      if (cookies.i18next === lng) return
      setCookie(cookieName, lng, { path: '/' })
    }, [lng, cookies.i18next])
  }
  return ret
}

Finalmente, en i18n/client.ts, puse a punto la parte del cliente de la internacionalización. Aquí, inicialicé i18next con react-i18next para integrarlo con React, LanguageDetector para detectar el idioma del usuario en el navegador y resourcesToBackend para cargar las traducciones desde el servidor backend. También definí una función useTranslation que se encarga de cambiar el idioma y guardar el idioma seleccionado en una cookie.

Componente React para cambiar de idioma

Te muestro un componente de React que he llamado LanguageSwitcher. Este componente te permite cambiar el idioma de la app. Verás que primero muestra el idioma que estás utilizando y al hacer clic en él, te despliega una lista con todos los idiomas disponibles. Cuando eliges uno de la lista la app cambia al idioma que seleccionaste.

'use client'

import { useState } from 'react'
import Link from 'next/link'
import { FalChevronRight } from '@/assets/icons/FalChevronRight'
import { languages } from '../settings'

export const LanguageSwitcher = ({ locale, className }: { locale: string, className?: string }) => {

  const [showLocales, setShowLocales] = useState(false)

  const handleShowLocales = () => {
    setShowLocales(!showLocales)
  }

  return (
    <div className={'relative cursor-pointer ml-3 LanguageSwitcher' + className } onClick={handleShowLocales}>
      <div
        className="text-center cursor-pointer "
        // className="route.params.url == item.url ? 'border-white' : 'border-transparent'"
      >
        <div className="flex items-center justify-between">
          <span className="mr-1">{ locale }</span>
          <span className="transition-all">
            <FalChevronRight
              className={"h-3 fill-white rotate-90 transition-all duration-150 " + (showLocales && '-rotate-90')} />
          </span>
        </div>
      </div>
      {
        showLocales &&
          <div className="flex flex-col absolute shadow-md bg-white">
            {languages.filter((l) => locale !== l).map((l) => {
              return (
                <Link className='hover:bg-black-300/10 px-2 py-1 text-black' key={l} href={`/${l}`}>
                  {l}
                </Link>
              )
            })}
          </div>
      }
    </div>
  )
}

Ahora el único punto que nos falta es incluir un middleware para redirigir a todos aquellos que intenten entrar en nuestra web sin incluir el string de idioma que hemos fijado.

Este código muestra cómo usamos un middleware en Next.js para manejar las redirecciones de URL basadas en el idioma de la app. El middleware verifica si ha establecido una cookie con el idioma actual. Si existe, saca el idioma de la cookie. Si no existe, saca el idioma de la cabecera 'Accept-Language' del navegador. Si ninguna de estas opciones da un idioma, establece un idioma predeterminado.

Después, el middleware comprueba si la URL de la solicitud incluye un idioma que soportamos. Si no es así y la URL no es una de las rutas internas de Next.js (como ‘/_next’), redirige la solicitud a una URL que incluye el idioma que hemos seleccionado.

Además, si la cabecera ‘referer’ de la solicitud contiene un idioma que soportamos, este idioma se guarda en una cookie. Esto puede ser útil para recordar el idioma preferido del usuario entre diferentes sesiones.

// middleware.ts

import { NextResponse, NextRequest } from 'next/server'
import acceptLanguage from 'accept-language'
import { cookieName, fallbackLng, languages } from './modules/i18n/settings'

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|assets|images|styles|icons|favicon.ico|sw.js).*)']
}

export async function middleware(request: NextRequest) {

  const response = NextResponse.next()

  let locale
  if (request.cookies.has(cookieName)) locale = acceptLanguage.get(request.cookies.get(cookieName)?.value)
  if (!locale) locale = acceptLanguage.get(request.headers.get('Accept-Language'))
  if (!locale) locale = fallbackLng

  // Redirect if locale in path is not supported
  if (
    !languages.some(locale => request.nextUrl.pathname.startsWith(`/${locale}`)) &&
    !request.nextUrl.pathname.startsWith('/_next')
  ) {
    return NextResponse.redirect(new URL(`/${locale}${request.nextUrl.pathname}`, request.url))
  }

  if (request.headers.has('referer')) {
    const refererUrl = new URL(request.headers.get('referer') ?? '')
    const lngInReferer = languages.find((locale) => refererUrl.pathname.startsWith(`/${locale}`))
    const response = NextResponse.next()
    if (lngInReferer) response.cookies.set(cookieName, lngInReferer)
    
  }

  return response

}

Conclusiones

¡Voilà! Acabamos de mostrarte cómo implementar la internacionalización en tu aplicación Next.js con react-i18next, i18next-resources-to-backend, e i18next-browser-languagedetector.

Ahora tu app está lista para conquistar el mundo, ¡eso suena genial! Si este tutorial te ha sido útil, ¿por qué no lo compartes en tus redes? Seguro que a tus colegas desarrolladores les puede ayudar.

Juan Núñez Blaco

Full Stack Developer