Saltar al contenido

Un sitio Next.js multilingüe, de punta a punta

Enrutado, mensajes, RTL, metadatos, hreflang, sitemaps y las partes que solo se rompen en el cuarto idioma. La arquitectura que entregamos, con las decisiones sin marcha atrás marcadas.

13 min de lectura

Casi todos los sitios Next.js multilingües se construyen dos veces. La primera versión enruta por idioma, traduce las cadenas, se publica, y luego pasa seis meses descubriendo que el sitemap lista un idioma, que el layout árabe es un desastre espejado, que los metadatos están en inglés en todas partes y que nadie puede añadir un idioma sin tocar cuarenta ficheros.

Esta es la arquitectura que evita la segunda construcción. Las decisiones caras de revertir están marcadas; el resto lo puedes cambiar un martes.

La forma de la URL, que después no puedes cambiar

Tres opciones, y solo las dos primeras son defendibles:

FormaEjemploVeredicto
Subdirectorioexample.com/de/blogPor defecto. Una autoridad de dominio, un despliegue.
Subdominiode.example.com/blogSolo si equipos distintos llevan los idiomas.
Parámetro de consultaexample.com/blog?lang=deNunca. Los rastreadores lo ven como una página.

Esta es la irreversible. Cambiarla después significa redirigir cada URL del sitio y esperar a que el índice se ponga al día, lo que lleva meses. Decídelo antes del primer despliegue.

Dentro de la forma de subdirectorio hay una segunda decisión: ¿lleva prefijo el idioma por defecto? Sin prefijo (/blog para inglés, /de/blog para alemán) deja la URL más corta para la audiencia mayor, y es lo que entregamos. Con prefijo (/en/blog) es más simétrico y más fácil de razonar. Las dos valen; mezclarlas no.

// i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
 
export const routing = defineRouting({
  locales: ['en', 'ar', 'de', 'es'],
  defaultLocale: 'en',
  // 'as-needed' deja el idioma por defecto sin prefijo. 'always' prefija
  // todos. Este ajuste decide la forma de tus URLs.
  localePrefix: 'as-needed',
});

Enrutar sin una redirección en cada petición

El segmento de idioma es un segmento dinámico, y el árbol entero vive debajo:

app/
  [locale]/
    layout.tsx
    page.tsx
    blog/
      page.tsx
      [slug]/page.tsx

Genera los params estáticos para que cada idioma se prerrenderice en vez de renderizarse bajo demanda:

// app/[locale]/page.tsx
import { routing } from '@/i18n/routing';
import { setRequestLocale } from 'next-intl/server';
 
export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}
 
export default async function Page({ params }) {
  const { locale } = await params;
  // Sin esto la página se pasa a render dinámico, porque leer el idioma de
  // la petición es una lectura en tiempo de petición. Una línea, y la
  // diferencia entre una ruta estática y un render por visitante.
  setRequestLocale(locale);
 
  // ...
}

setRequestLocale es la línea que se omite, y el síntoma es una salida de build donde cada ruta localizada muestra ƒ en vez de . Te cuesta el CDN en todas las páginas del sitio.

Mensajes, y no enviarlos todos

Carga los mensajes de un idioma, no de cuatro. El import ingenuo de un índice de mensajes mete todos los idiomas en todos los bundles.

// i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
 
export default getRequestConfig(async ({ requestLocale }) => {
  const requested = await requestLocale;
  const locale = routing.locales.includes(requested) ? requested : routing.defaultLocale;
 
  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

El import dinámico es lo que mantiene los otros tres fuera del payload. Compruébalo: si de.json aparece en el chunk de la ruta inglesa, el import se elevó a algún sitio.

Mantén los ficheros de mensajes estructuralmente idénticos. Una clave que falta en un idioma es un error de ejecución en producción y en ningún otro sitio, así que merece un test:

// Una clave presente en inglés tiene que existir en todos los idiomas.
const flatten = (obj, prefix = '') =>
  Object.entries(obj).flatMap(([k, v]) =>
    typeof v === 'object' && !Array.isArray(v)
      ? flatten(v, `${prefix}${k}.`)
      : [`${prefix}${k}`],
  );
 
for (const locale of ['ar', 'de', 'es']) {
  const missing = flatten(en).filter((k) => !flatten(messages[locale]).includes(k));
  if (missing.length) throw new Error(`${locale} no tiene: ${missing.join(', ')}`);
}

RTL es un problema de layout, no de traducción

El árabe es donde un layout pensado solo para el alfabeto latino se deshace, y el arreglo no es una hoja de estilos por dirección. Es escribir el layout con propiedades lógicas desde el principio, para que la dirección sea un dato y no una rama.

<html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>

Entonces cada valor horizontal es lógico:

FísicoLógicoQué significa
ml-4ms-4margen al inicio de la línea
pr-6pe-6relleno al final de la línea
left-0start-0desplazamiento al inicio
text-lefttext-startalineado al inicio

Escrito así, el layout árabe se refleja solo y no hay una segunda hoja de estilos que mantener sincronizada. Tres cosas siguen siendo físicas a propósito: los bloques de código, que son LTR en todos los idiomas; los iconos que codifican dirección, como una flecha de "siguiente", que necesitan un giro explícito; y los números, que no se reflejan.

[dir='rtl'] .rtl-flip {
  transform: scaleX(-1);
}

Una más que pilla a mucha gente: el árabe es una escritura ligada, y el interletraje negativo que deja apretado un titular latino separa las uniones y es sencillamente incorrecto para él. Detén el tracking en la frontera del idioma.

Cambiar de idioma sin perder la página

El selector de idioma que manda a todo el mundo a la portada es el fallo más común de toda esta arquitectura, y el que los visitantes sí notan. Alguien que está leyendo un artículo en inglés pulsa alemán y aterriza en la portada alemana, habiendo perdido lo que leía.

Cambia sobre la ruta actual, no sobre la raíz:

'use client';
 
import { usePathname, useRouter } from '@/i18n/navigation';
 
export function LocaleSwitcher({ current }: { current: Locale }) {
  // Este pathname viene sin el idioma: en /de/blog/x devuelve /blog/x, así
  // que el mismo valor sirve para cualquier idioma de destino.
  const pathname = usePathname();
  const router = useRouter();
 
  return routing.locales.map((locale) => (
    <button
      key={locale}
      lang={locale}
      aria-current={locale === current ? 'true' : undefined}
      onClick={() => router.replace(pathname, { locale })}
    >
      {names[locale]}
    </button>
  ));
}

Un aviso que conviene conocer antes de que lo encuentre un visitante: una página que no existe en el idioma de destino dará 404. O escondes los idiomas para los que una página no tiene traducción, o los llevas al padre traducido, pero decide, porque por defecto es un callejón sin salida.

Metadatos, por página y por idioma

Cada página necesita su propio título, descripción, canónico y alternates, en su idioma. Generados desde un único helper, o se desincronizan:

// lib/seo.ts
export function buildMetadata({ locale, title, description, path, availableLocales }) {
  const url = absoluteUrl(locale, path);
 
  return {
    title,
    description,
    alternates: {
      canonical: url,
      languages: {
        // Solo los idiomas que de verdad tienen esta página. Apuntar
        // hreflang="de" a una página en inglés es peor que no poner hreflang.
        ...Object.fromEntries(
          availableLocales.map((l) => [l, absoluteUrl(l, path)]),
        ),
        'x-default': absoluteUrl(defaultLocale, path),
      },
    },
    openGraph: { url, title, description, locale },
  };
}

La regla que importa: hreflang tiene que reflejar lo que existe. Un grupo que anuncia cuatro traducciones de las cuales dos son la página inglesa es un grupo del que los buscadores aprenden a desconfiar. Deriva la lista del sistema de ficheros, no de una constante. El razonamiento está desarrollado en hreflang correcto en Next.js, generado y no mantenido.

El sitemap, desde la misma fuente

Un sitemap, todos los idiomas, con los alternates en cada entrada:

// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
  return routes.flatMap((route) =>
    localesFor(route).map((locale) => ({
      url: absoluteUrl(locale, route.path),
      lastModified: route.updated,
      alternates: {
        languages: Object.fromEntries(
          localesFor(route).map((l) => [l, absoluteUrl(l, route.path)]),
        ),
      },
    })),
  );
}

routes sale de la misma función que usan las páginas. Un sitemap construido desde una lista mantenida a mano contradice al router en menos de un mes: canónicos y sitemaps que no pueden desincronizarse.

Lo que comprobamos antes de publicar un idioma

No es una lista para la memoria de quien programa: son scripts que tumban el build.

  1. Cada clave de mensaje existe en cada idioma. Una clave que falta es un error en producción, en un solo idioma.
  2. Sin scroll horizontal, a 320px, en todos los idiomas. Los compuestos alemanes son un tercio más largos que el inglés y no hay diccionario que los parta. Es con diferencia la forma más habitual de que se rompa un sitio multilingüe.
  3. Sin cortes a mitad de palabra. El recurso que evita el desbordamiento parte una palabra antes que ensanchar la página, y eso parece un fallo porque lo es.
  4. Cada canónico apunta a su propia dirección, en cada idioma, y la forma con prefijo del idioma por defecto redirige.
  5. Los grupos de hreflang son recíprocos. Si la página alemana lista español, la española tiene que listar alemán.

Las dos primeras cazan los errores que se publican. Las tres últimas cazan los que te cuestan posiciones en silencio, seis semanas después, sin nada en los logs.

La parte que no es ingeniería

Una cadena traducida no es una página traducida. La intención de búsqueda cambia por mercado: la frase alemana que teclea un comprador no es una traducción de la inglesa, es otra frase con otro volumen. Traducir tu investigación de palabras clave te da páginas que no posicionan para nada en tres idiomas.

Presupuesta eso aparte, y trata la arquitectura de arriba como lo que hace posible actuar sobre la respuesta, no como la respuesta.

Volver a todos los artículos