Cuando montamos alquila-sabanas, uno de nuestros proyectos en Next.js, tuvimos claro desde el primer commit que la web iba a tener que hablar en varios idiomas. No fue un apaño de última hora con textos duplicados, sino una decisión de arquitectura desde el principio. Elegimos next-intl para resolverlo, y el resultado es una estructura que hoy nos sirve de referencia para cualquier proyecto nuevo que empiece con esta necesidad.
Este artículo recoge cómo organizamos las piezas: las carpetas i18n/ y messages/, el proxy.ts que decide el idioma antes de que se renderice nada, la carga de traducciones tanto en componentes de servidor como de cliente, el formateo de fechas y monedas, y el SEO multidioma con hreflang y sitemap.
La base: routing y el middleware en proxy.ts
next-intl separa la configuración de idiomas del resto del código en un fichero de routing, que vive dentro de i18n/. Ahí definimos qué locales soportamos y cuál es el idioma por defecto:
// i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['es', 'en', 'fr'],
defaultLocale: 'es'
});Este objeto routing se reutiliza en varios sitios: en el middleware, en la navegación tipada y en el sitemap. Es la única fuente de verdad sobre qué idiomas existen, así que si mañana añadimos italiano, lo tocamos en un solo lugar.
El middleware (en nuestro caso lo mantenemos como proxy.ts en la raíz del proyecto) intercepta cada petición y decide qué prefijo de idioma corresponde antes de llegar a las rutas:
// proxy.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: ['/((?!api|_next|.*\\..*).*)']
};Con esto, /es/reservas, /en/bookings o /fr/reservations conviven sin que tengamos que escribir lógica de redirección a mano. El matcher excluye rutas de API y recursos estáticos, que no necesitan prefijo de idioma.
Cargar mensajes: Server Components y Client Components
La gran ventaja de next-intl frente a soluciones más antiguas de i18n en React es que trata de forma distinta, pero coherente, los componentes de servidor y los de cliente, que en Next.js con App Router conviven en la misma página.
En componentes de servidor
En el layout raíz del locale cargamos los mensajes una vez y los pasamos al provider:
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
export default async function LocaleLayout({ children, params }) {
const { locale } = await params;
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}Cualquier Server Component dentro del árbol puede usar getTranslations de forma asíncrona sin depender del provider, lo que resulta cómodo para páginas que generan metadatos o contenido estático en el servidor.
En componentes de cliente
Para un botón de reserva que necesita interactividad, usamos el hook estándar, apoyado en el NextIntlClientProvider que ya envuelve la aplicación:
'use client';
import { useTranslations } from 'next-intl';
export function BookingButton() {
const t = useTranslations('Booking');
return <button>{t('reserve')}</button>;
}Lo importante es que NextIntlClientProvider solo envía al navegador los mensajes que realmente se usan en el árbol de cliente, no todo el diccionario. Eso mantiene el bundle ligero incluso con varios idiomas cargados.
Formateo de fechas y monedas
En un negocio de alquiler, las fechas de disponibilidad y los precios aparecen en casi todas las pantallas. No basta con traducir la etiqueta "precio": hay que formatear el número según la convención del idioma. next-intl expone useFormatter precisamente para esto:
import { useFormatter } from 'next-intl';
export function PriceTag({ date, amount }: { date: Date; amount: number }) {
const format = useFormatter();
return (
<span>
{format.dateTime(date, { dateStyle: 'long' })} — {format.number(amount, { style: 'currency', currency: 'EUR' })}
</span>
);
}Para un usuario en francés, la misma fecha se escribe de otra forma y el separador decimal del precio cambia. Delegar esto en el formateador de Intl que envuelve next-intl nos evita bugs sutiles del tipo "funciona en español pero el inglés muestra la coma mal puesta".
SEO multidioma: hreflang y sitemap
De poco sirve tener la web traducida si los buscadores no saben que existen varias versiones de la misma página. Dos piezas resuelven esto:
- •Etiquetas hreflang, generadas en generateMetadata con alternates.languages, para que cada página declare sus equivalentes en otros idiomas.
- •Un sitemap que recorre los locales definidos en routing y construye las URLs alternativas automáticamente.
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { routing } from '@/i18n/routing';
export default function sitemap(): MetadataRoute.Sitemap {
return routing.locales.map((locale) => ({
url: `https://alquila-sabanas.com/${locale}`,
alternates: {
languages: Object.fromEntries(
routing.locales.map((l) => [l, `https://alquila-sabanas.com/${l}`])
)
}
}));
}Al construir el sitemap a partir del mismo routing que usa el middleware, nos aseguramos de que nunca se desincronizan: si se añade un idioma, aparece en rutas, en hreflang y en el mapa del sitio sin tocar tres sitios distintos.
Cómo organizamos las traducciones en messages/
La carpeta messages/ contiene un JSON por idioma, pero dentro de cada fichero agrupamos las claves por sección funcional en lugar de volcarlas todas en un único nivel:
{
"Common": {
"appName": "Alquila Sábanas"
},
"Booking": {
"reserve": "Reservar",
"cancel": "Cancelar"
},
"Footer": {
"contact": "Contacto"
}
}Esta organización por namespaces (Common, Booking, Footer) nos permite usar useTranslations('Booking') en lugar de rutas largas tipo t('booking.actions.reserve'), y hace que al revisar un fichero se vea de un vistazo qué parte de la aplicación cubre cada bloque.
Mantener los tres idiomas con la misma estructura de claves es lo más delicado del día a día: si alguien añade una clave en es.json y se olvida de en.json, el fallo no siempre es evidente hasta que un usuario navega en ese idioma. En este punto incorporamos un agente de IA en el flujo interno: antes de cada despliegue, revisa que las claves coincidan entre ficheros y sugiere la traducción que falta, pero la aprobación final de cualquier texto que vaya a producción la hace siempre una persona del equipo.
Lo que nos llevamos de esta arquitectura
Si vas a internacionalizar un proyecto en Next.js desde cero, el orden que mejor nos ha funcionado es: define primero el routing y el middleware, luego la carga de mensajes en servidor y cliente, después el formateo con Intl, y deja el SEO multidioma para el final, cuando las rutas ya son estables. Intentar hacerlo al revés suele acabar en hreflang mal generados porque las URLs todavía cambian.
Traducir textos sueltos no basta: internacionalizar es decidir desde el principio cómo va a navegar, formatear y posicionarse cada versión de la web.
Como siguiente paso, si tu proyecto ya tiene next-intl instalado pero los namespaces de messages/ han crecido sin orden, merece la pena dedicar una tarde a reagruparlos por sección antes de que el fichero se vuelva imposible de mantener.
