Publicado el 14 de octubre de 2026
Un sitio en cuatro idiomas con Next.js 16 y next-intl
Cómo llevamos kitusoft.com a español, inglés, francés y alemán: estructura de archivos, URLs por idioma, hreflang, contenido de base de datos y las decisiones que más pesan en SEO.
nextjs · next-intl · i18n · seo
kitusoft.com está en español, inglés, francés y alemán. Lo construimos con Next.js 16 (App Router) y next-intl. En este artículo contamos cómo está organizado y qué decisiones tomamos, con la documentación oficial a la mano.
La estructura mínima
src/
i18n/
routing.ts <- idiomas, idioma por defecto y rutas
request.ts <- carga los mensajes del idioma de cada petición
navigation.ts <- Link, useRouter, usePathname conscientes del idioma
proxy.ts <- detecta el idioma y redirige
app/[locale]/... <- todas las páginas
messages/
es.json en.json fr.json de.json
Un detalle de Next.js 16: el archivo que antes se llamaba middleware.ts ahora se llama proxy.ts (el nombre anterior quedó obsoleto). La propia documentación de next-intl ya usa el nombre nuevo.
La configuración, en un solo lugar
export const routing = defineRouting({
locales: ["es", "en", "fr", "de"],
defaultLocale: "es",
localePrefix: "always",
localeDetection: false,
pathnames: {
"/servicios": { es: "/servicios", en: "/services", fr: "/services", de: "/leistungen" },
"/contacto": { es: "/contacto", en: "/contact", fr: "/contact", de: "/kontakt" },
// ...
},
});
Tres decisiones están en esas líneas.
1. localePrefix: "always": cada idioma con su URL
next-intl ofrece tres modos:
always(el predeterminado): todas las URLs empiezan con el idioma, por ejemplo/es/servicioso/en/services.as-needed: el idioma por defecto va sin prefijo (/servicios) y los demás con prefijo (/en/services).never: el idioma no aparece en la URL. En este modo, next-intl desactiva los enlaces alternativos.
Elegimos always: cada versión tiene una dirección propia y estable, y es fácil declarar a los buscadores cuál es la traducción de cuál.
2. localeDetection: false: no adivinar el idioma
Por defecto, next-intl decide el idioma según el encabezado Accept-Language del navegador o una cookie. Con localeDetection: false solo cuenta el prefijo de la URL. Somos un estudio ecuatoriano y preferimos que quien llega a la raíz vea primero el español; cambiar de idioma está a un clic.
3. pathnames: URLs traducidas
Con pathnames cada página puede tener una URL distinta por idioma, mientras el código vive en una sola carpeta. Por ejemplo, /es/servicios, /en/services y /de/leistungen se sirven desde app/[locale]/servicios/page.tsx. Las URLs del blog, las apps y los documentos legales son iguales en todos los idiomas a propósito, porque otros sitios ya enlazan a ellas.
Si cambias URLs que ya existían, agrega redirecciones permanentes (308) desde las antiguas. Nosotros lo hicimos al pasar de /en/servicios a /en/services.
Una página con traducciones
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("services");
return <h1>{t("title")}</h1>;
}
Si falta una clave, next-intl muestra un error en la consola y pinta el nombre de la clave (services.title) en lugar del texto. Para que eso no llegue a producción, una prueba automática compara los cuatro archivos de mensajes y falla si un idioma tiene claves de menos o de más.
hreflang: dos capas
Para que los buscadores sepan que /es/servicios y /en/services son la misma página en dos idiomas:
- next-intl ya lo hace en el proxy: por defecto envía un encabezado HTTP
Linkcon una entradahreflangpor idioma y unax-default. Se puede desactivar conalternateLinks: false. - Además lo declaramos en el HTML, con la Metadata API de Next.js, y en el sitemap:
export async function generateMetadata({ params }) {
const { locale } = await params;
return {
alternates: {
canonical: `/${locale}/...`,
languages: { es: "/es/servicios", en: "/en/services", fr: "/fr/services", de: "/de/leistungen", "x-default": "/es/servicios" },
},
};
}
En kitusoft.com una función genera esas URLs a partir de la misma configuración de pathnames, así que nunca se desalinean.
El selector de idioma
Nuestro selector es un botón que despliega una lista de enlaces reales, uno por idioma, a la misma página en ese idioma. Al ser enlaces, se pueden abrir en otra pestaña y el navegador los entiende sin trucos. Un <select> necesitaría JavaScript para navegar.
En páginas cuyo contenido cambia según el idioma, como las entradas del blog, el selector lleva al índice del blog en el otro idioma: cada traducción tiene su propio slug y enlazar el mismo slug daría un 404.
Contenido que vive en la base de datos
Los textos de la interfaz están en los JSON. El contenido de Firestore se resolvió así:
- Blog: cada artículo son dos documentos, uno en español y otro en inglés, unidos por una
translationKey. El panel de administración los edita lado a lado. El blog solo existe en esos dos idiomas; en francés y alemán,/blogmuestra un aviso que lleva a las versiones existentes y no se indexa. - Tienda: un producto es un solo documento, igual en todos los idiomas.
Lo que aprendimos
- Decide las URLs al principio. Cambiarlas después obliga a mantener redirecciones.
- Prueba la paridad de traducciones. Una prueba que compare las claves evita textos faltantes en producción.
- hreflang en encabezado, HTML y sitemap. next-intl cubre el encabezado; el resto es poco código y elimina ambigüedades.
- Separa interfaz y contenido. Los JSON son para la interfaz; el contenido editorial necesita su propio modelo de traducción.
Fuentes
- next-intl: Configuración de rutas: localePrefix, localeDetection, pathnames y alternateLinks (en inglés).
- next-intl: Proxy / middleware (en inglés).
- next-intl: Manejo de errores y mensajes faltantes (en inglés).
- Next.js: Actualizar a la versión 16: de middleware a proxy (en inglés).