Cuando necesitas un sitio web en varios idiomas, el primer impulso es buscar un plugin. Astro-i18next, Paraglide, astro-i18n-aut — las opciones sobran. Pero cada plugin trae dependencias, impone una estructura de carpetas y hace cosas que quizás no necesitas. Y si quieres tener el control total? Y si necesitas URLs traducidas — /über-mich en alemán, /about en inglés, /sobre-mi en español — sin nada opaco de por medio?
Exactamente eso es lo que construí para este sitio web: trilingüe, usando el enrutamiento i18n nativo de Astro y una única tabla de slugs. Sin plugins, sin magia, completamente transparente. En este tutorial te explico cada pieza paso a paso.
Astro ofrece soporte de enrutamiento i18n integrado que te permite servir contenido en múltiples idiomas — con control total sobre los patrones de URL y la lógica de fallback.
El problema con los plugins de i18n
Los plugins de internacionalización resuelven un problema real, pero a menudo vienen con suposiciones que no encajan en todos los proyectos. Algunos generan carpetas automáticamente, otros exigen convenciones específicas de nombres de archivo. La mayoría no traduce URLs — terminas con /en/über-mich en lugar de /en/about. Y cuando necesitas depurar un problema, te encuentras con código abstraído que no escribiste.
El enrutamiento i18n nativo de Astro te da la base: prefijos basados en locale, detección del locale por defecto y negociación de idioma. Lo que no proporciona es la traducción de slugs — y ahí es precisamente dónde entra nuestra solución personalizada.
Vista general de la arquitectura
El concepto es sencillo: el alemán es el locale por defecto y no lleva prefijo en la URL. El inglés obtiene /en/, el español /es/. Cada página tiene un slug semánticamente apropiado en cada idioma — sin parámetros id, sin rutas UUID, sin prefijos de idioma delante de slugs alemanes.
En astro.config.mjs defines los tres locales y estableces prefixDefaultLocale en false. Así el alemán no tiene prefijo — /kontakt en lugar de /de/kontakt. Inglés y español obtienen automáticamente /en/ y /es/ como prefijos. Esa es la única configuración que Astro necesita.
Un archivo TypeScript central (src/i18n/utils.ts) contiene una tabla que mapea cada ruta de página a sus tres variantes de idioma. /kontakt se convierte en /contact (EN) y /contacto (ES). Un mapa de búsqueda inversa permite la resolución en ambas direcciones — desde cualquier slug hacia cualquier slug destino.
Dos funciones principales usan la tabla: getLocalePath() genera rutas conscientes del locale para navegación y enlaces. getLocalePathFromCurrent() toma la URL actual y genera la ruta equivalente en otro idioma — perfecto para el selector de idioma.
Tres archivos JSON (de.json, en.json, es.json) contienen todos los textos de la interfaz como pares clave-valor planos. La función t(locale, key) devuelve la cadena traducida, con fallback automático al alemán. Simple, type-safe y sin sobrecarga en tiempo de ejecución.
La configuración i18n en Astro
El punto de partida es astro.config.mjs. Aquí le dices a Astro que idiomas soporta tu sitio y cómo funciona el enrutamiento.
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://arnoldwender.com',
output: 'static',
// i18n — DE (default, sin prefijo), EN y ES con prefijo
i18n: {
defaultLocale: 'de',
locales: ['de', 'en', 'es'],
routing: {
prefixDefaultLocale: false,
},
},
}); Con estas tres líneas de configuración, Astro entiende que:
- Las páginas en
src/pages/pertenecen a la versión alemana (sin prefijo) - Las páginas en
src/pages/en/son la versión inglesa (prefijo/en/) - Las páginas en
src/pages/es/son la versión española (prefijo/es/) Astro.currentLocaleproporciona automáticamente el idioma actual en cada página
Lo que Astro no hace: no traduce slugs. /en/kontakt es una ruta inglesa válida para Astro — pero semánticamente sin sentido. Aquí comienza nuestro trabajo personalizado.
La tabla de slugs
El corazón de la solución es una tabla sencilla que mapea cada ruta a sus variantes de idioma. Vive en src/i18n/utils.ts y deliberadamente no tiene dependencias.
// src/i18n/utils.ts — Mapeo de slugs trilingue
const slugTable: { de: string; en: string; es: string }[] = [
{ de: '/kontakt', en: '/contact', es: '/contacto' },
{ de: '/über-mich', en: '/about', es: '/sobre-mi' },
{ de: '/preise', en: '/pricing', es: '/precios' },
{ de: '/datenschutz', en: '/privacy', es: '/privacidad' },
{ de: '/barrierefreiheit', en: '/accessibility', es: '/accesibilidad' },
{ de: '/impressum', en: '/impressum', es: '/aviso-legal' },
{ de: '/blog', en: '/blog', es: '/blog' },
{ de: '/leistungen', en: '/services', es: '/servicios' },
{ de: '/faq', en: '/faq', es: '/faq' },
{ de: '/links', en: '/links', es: '/enlaces' },
{ de: '/stack', en: '/stack', es: '/stack' },
{ de: '/sitemap', en: '/sitemap', es: '/mapa-del-sitio' },
]; // Busqueda inversa — cualquier slug de cualquier idioma → entrada completa
const slugToEntry = new Map<string, { de: string; en: string; es: string }>();
for (const entry of slugTable) {
slugToEntry.set(entry.de, entry);
slugToEntry.set(entry.en, entry);
slugToEntry.set(entry.es, entry);
}
// Ejemplo: slugToEntry.get('/contact')
// → { de: '/kontakt', en: '/contact', es: '/contacto' }
//
// slugToEntry.get('/contacto')
// → { de: '/kontakt', en: '/contact', es: '/contacto' } La tabla es deliberadamente plana. Cada entrada es un objeto con tres cadenas — sin estructuras anidadas, sin IDs, sin metadatos. Esto la hace fácil de leer, fácil de extender y fácil de depurar.
El mapa slugToEntry es el truco clave: registra cada slug de cada idioma como clave y apunta a la misma entrada. Así puedes buscar cualquier slug — /contact (EN), /contacto (ES) o /kontakt (DE) — y encontrar la entrada para resolver el slug de cualquier otro idioma. La resolución funciona en todas las direcciones.
El helper getLocalePath()
La primera función central genera rutas conscientes del locale. Se usa en la navegación, enlaces internos y en cualquier lugar dónde necesites una ruta para un idioma específico.
/**
* Generar ruta con prefijo de locale y traduccion de slug.
* DE (default) no lleva prefijo y usa slugs alemanes.
* EN/ES llevan prefijo con codigo de locale y usan slugs localizados.
* Acepta slugs de CUALQUIER idioma — resuelve al locale destino.
*/
export function getLocalePath(locale: string | undefined, path: string): string {
const lang = (locale || 'de') as Locale;
const entry = slugToEntry.get(path);
if (lang === 'de') {
// DE (default) — sin prefijo, devolver slug aleman
return entry ? entry.de : path;
}
// EN/ES — prefijo con codigo de locale, usar slug especifico del idioma
const targetSlug = entry ? entry[lang] : path;
return `/${lang}${targetSlug}`;
} ---
// En cualquier componente Astro
import { getLocalePath } from '../i18n/utils';
const locale = Astro.currentLocale || 'de';
---
<!-- Navegacion con slugs traducidos -->
<nav>
<a href={getLocalePath(locale, '/kontakt')}>
{t(locale, 'nav.contact')}
</a>
<a href={getLocalePath(locale, '/über-mich')}>
{t(locale, 'nav.about')}
</a>
<a href={getLocalePath(locale, '/leistungen')}>
{t(locale, 'nav.services')}
</a>
</nav>
<!-- Resultados por locale:
DE: /kontakt, /über-mich, /leistungen
EN: /en/contact, /en/about, /en/services
ES: /es/contacto, /es/sobre-mi, /es/servicios
--> El punto crucial: puedes pasar la ruta de entrada en cualquier idioma. Ya sea que pases /kontakt, /contact o /contacto — la función encuentra la entrada y devuelve el slug correcto para el idioma destino. Esto hace que los componentes de navegación sean agnósticos al idioma: los escribes una vez con slugs alemanes, y la función traduce automáticamente.
El selector de idioma
El selector de idioma es la pieza que une todo. Necesita tomar la URL actual, determinar que página esta viendo el usuario y generar la ruta correspondiente en el idioma destino.
/**
* Normalizar un pathname eliminando barras finales (excepto root /).
* Asegura busqueda consistente de slugs sin importar el comportamiento del dev server.
*/
function normalizePath(pathname: string): string {
return pathname === '/' ? '/' : pathname.replace(/\/+$/, '');
}
/**
* Traducir un pathname completo a la ruta equivalente en el idioma destino.
* Usado por LanguageSwitcher para construir enlaces cross-locale correctos.
*/
export function getLocalePathFromCurrent(
targetLocale: string,
currentPathname: string
): string {
// Eliminar prefijo de locale y barras finales → ruta base limpia
const stripped = currentPathname.replace(/^\/(en|es)(\/|$)/, '/') || '/';
const basePath = normalizePath(stripped);
const entry = slugToEntry.get(basePath);
const lang = targetLocale as Locale;
if (lang === 'de') {
// DE (default) — sin prefijo, devolver slug aleman
return entry ? entry.de : basePath;
}
// EN/ES — prefijo con codigo de locale, usar slug especifico del idioma
const targetSlug = entry ? entry[lang] : basePath;
return `/${lang}${targetSlug === '/' ? '' : targetSlug}` || `/${lang}`;
} ---
// LanguageSwitcher.astro (simplificado)
import Icon from '../atoms/Icon.astro';
import { getLocalePathFromCurrent } from '../../i18n/utils';
const currentLocale = Astro.currentLocale || 'de';
const pathname = Astro.url.pathname;
const locales = [
{ code: 'de', label: 'Deutsch', flag: 'DE' },
{ code: 'en', label: 'English', flag: 'EN' },
{ code: 'es', label: 'Español', flag: 'ES' },
];
function getLocalizedPath(locale: string): string {
return getLocalePathFromCurrent(locale, pathname);
}
---
<div class="lang-switcher">
<button aria-expanded="false" aria-haspopup="listbox">
<Icon name="globe" size={16} />
<span>{currentLocale.toUpperCase()}</span>
</button>
<ul role="listbox" aria-label="Seleccionar idioma">
{locales.map((locale) => (
<li role="option" aria-selected={locale.code === currentLocale}>
<a href={getLocalizedPath(locale.code)} hreflang={locale.code}>
<span>{locale.flag}</span>
<span>{locale.label}</span>
</a>
</li>
))}
</ul>
</div> La función getLocalePathFromCurrent() opera en tres pasos:
-
Eliminar el prefijo: El regex
/^\/(en|es)(\/|$)/elimina/en/o/es/del inicio de la URL. Lo que queda es el slug crudo — sin importar en que idioma se encuentre actualmente. -
Buscar el slug: La ruta limpia se busca en el mapa
slugToEntry. Si queda/contact, el mapa encuentra la entrada con las tres versiones de idioma. -
Ensamblar la ruta destino: Para alemán no hay prefijo, para EN/ES se antepone el código de locale.
Los archivos JSON de traducción
Además de los slugs, también necesitamos textos de interfaz traducidos: etiquetas de navegación, texto de botones, etiquetas de formularios, enlaces del footer. Estos viven en tres archivos JSON con claves idénticas.
{
"nav.home": "Startseite",
"nav.about": "Über mich",
"nav.contact": "Kontakt",
"nav.services": "Leistungen",
"nav.blog": "Blog",
"nav.faq": "FAQ",
"hero.cta.primary": "Kontakt aufnehmen",
"hero.cta.secondary": "Portfolio ansehen",
"footer.copyright": "Alle Rechte vorbehalten.",
"cookie.message": "Nur essenzielle Cookies — kein Tracking.",
"cookie.accept": "Akzeptieren"
} {
"nav.home": "Home",
"nav.about": "About",
"nav.contact": "Contact",
"nav.services": "Services",
"nav.blog": "Blog",
"nav.faq": "FAQ",
"hero.cta.primary": "Get in Touch",
"hero.cta.secondary": "View Portfolio",
"footer.copyright": "All rights reserved.",
"cookie.message": "Only essential cookies — no tracking.",
"cookie.accept": "Accept"
} {
"nav.home": "Inicio",
"nav.about": "Sobre mi",
"nav.contact": "Contacto",
"nav.services": "Servicios",
"nav.blog": "Blog",
"nav.faq": "FAQ",
"hero.cta.primary": "Contacto",
"hero.cta.secondary": "Ver Portfolio",
"footer.copyright": "Todos los derechos reservados.",
"cookie.message": "Solo cookies esenciales — sin rastreo.",
"cookie.accept": "Aceptar"
} El helper t() es deliberadamente minimalista:
import en from './locales/en.json';
import de from './locales/de.json';
import es from './locales/es.json';
type Locale = 'de' | 'en' | 'es';
const translations: Record<Locale, Record<string, string>> = { de, en, es };
/**
* Obtener cadena traducida por clave para el locale dado.
* Fallback a aleman (default) si la clave no existe en el locale destino.
*/
export function t(locale: string | undefined, key: string): string {
const lang = (locale || 'de') as Locale;
return translations[lang]?.[key] || translations.de[key] || key;
} Sin anidación, sin namespaces, sin interpolación. Un objeto plano clave-valor por idioma y una función que devuelve el valor. El fallback a alemán asegura que las traducciones faltantes nunca produzcan una cadena vacía — siempre ves al menos el texto alemán, y si ese también falta, la propia clave. Invaluable para depuración.
La estructura de archivos
Aquí tienes una visión general de como encajan todas las piezas:
Vista general de la arquitectura
- astro.config.mjs
Define defaultLocale: "de", locales: ["de", "en", "es"] y prefixDefaultLocale: false. Esta es toda la configuración del framework — tres líneas que le dicen a Astro como mapear rutas a idiomas.
- src/i18n/utils.ts
Contiene la tabla de slugs, el mapa de búsqueda inversa, getLocalePath(), getLocalePathFromCurrent(), t() y getTranslations(). Un solo archivo de ~110 líneas que impulsa todo el sistema i18n.
- src/i18n/locales/*.json
Tres archivos JSON (de.json, en.json, es.json) con claves idénticas y valores específicos por idioma. Contienen todos los textos de la interfaz: navegación, botones, formularios, mensajes de error, metadatos SEO.
- src/pages/en/*.astro y src/pages/es/*.astro
Para cada página existe una versión inglesa y española bajo la carpeta de locale respectiva. Los nombres de archivo coinciden con los slugs traducidos: pages/en/about.astro se convierte en /en/about, pages/es/sobre-mi.astro se convierte en /es/sobre-mi.
- Componentes y Layouts
Header, footer y navegación usan getLocalePath() y t() con Astro.currentLocale para renderizar enlaces y etiquetas según el idioma. Sin duplicación — un componente para los tres idiomas.
src/
├── i18n/
│ ├── utils.ts ← Tabla de slugs + funciones auxiliares
│ └── locales/
│ ├── de.json ← Textos de interfaz en aleman
│ ├── en.json ← Textos de interfaz en ingles
│ └── es.json ← Textos de interfaz en espanol
├── pages/
│ ├── index.astro ← Pagina principal DE (/)
│ ├── kontakt.astro ← Contacto DE (/kontakt)
│ ├── über-mich.astro ← Sobre mi DE (/über-mich)
│ ├── leistungen.astro ← Servicios DE (/leistungen)
│ ├── en/
│ │ ├── index.astro ← Pagina principal EN (/en/)
│ │ ├── contact.astro ← Contacto EN (/en/contact)
│ │ ├── about.astro ← Sobre mi EN (/en/about)
│ │ └── services.astro ← Servicios EN (/en/services)
│ └── es/
│ ├── index.astro ← Pagina principal ES (/es/)
│ ├── contacto.astro ← Contacto ES (/es/contacto)
│ ├── sobre-mi.astro ← Sobre mi ES (/es/sobre-mi)
│ └── servicios.astro ← Servicios ES (/es/servicios)
├── components/
│ └── molecules/
│ └── LanguageSwitcher.astro ← Usa getLocalePathFromCurrent()
└── layouts/
└── BaseLayout.astro ← Usa t() y getLocalePath()
Basado en plugins vs. solución propia
| Aspecto | Basado en plugin | Tabla de slugs propia Recommended |
|---|---|---|
| Tamaño del bundle | Dependencia JS adicional | Cero sobrecarga en tiempo de ejecución |
| Control de URLs | El plugin decide el formato de ruta | Control total sobre cada slug |
| Flexibilidad | Limitado a la API del plugin | Infinitamente extensible |
| Esfuerzo de setup | npm install + configuración | Un archivo con ~110 líneas |
| Traducción de slugs | A menudo no soportada | Funcionalidad central |
| Depuración | Entender internos del plugin | Tu propio código, transparencia total |
| Actualizaciones | Depende de los mantenedores del plugin | Sin dependencias externas |
| TypeScript | Varia según el plugin | Integración nativa con TypeScript |
Aspectos SEO: hreflang y Canonical
Las URLs traducidas son solo la mitad del trabajo. Los motores de búsqueda necesitan saber que páginas están relacionadas. Para eso existen las etiquetas hreflang en el <head>:
<!-- En la pagina alemana /kontakt -->
<link rel="alternate" hreflang="de" href="https://arnoldwender.com/kontakt" />
<link rel="alternate" hreflang="en" href="https://arnoldwender.com/en/contact" />
<link rel="alternate" hreflang="es" href="https://arnoldwender.com/es/contacto" />
<link rel="alternate" hreflang="x-default" href="https://arnoldwender.com/kontakt" />
<link rel="canonical" href="https://arnoldwender.com/kontakt" /> ---
// En SEO.astro o BaseLayout.astro
import { getLocalePathFromCurrent, locales, defaultLocale } from '../i18n/utils';
const currentPath = Astro.url.pathname;
const siteUrl = 'https://arnoldwender.com';
---
{locales.map((locale) => (
<link
rel="alternate"
hreflang={locale}
href={`${siteUrl}${getLocalePathFromCurrent(locale, currentPath)}`}
/>
))}
<link
rel="alternate"
hreflang="x-default"
href={`${siteUrl}${getLocalePathFromCurrent(defaultLocale, currentPath)}`}
/> Cada versión de idioma de una página referencia a todas las demas versiones. La etiqueta x-default apunta a la versión alemana como fallback. Así Google sabe que /kontakt, /en/contact y /es/contacto son traducciones de la misma página — y muestra al usuario la versión correcta en los resultados de búsqueda. Para profundizar en como los datos estructurados mejoran la visibilidad en buscadores, consulta nuestra guía sobre JSON-LD y datos estructurados.
Buenas prácticas
Construye siempre primero la versión alemana (por defecto) completa. Las versiones EN/ES son copias con contenido traducido y slugs ajustados. Así tienes un producto funcional antes de internacionalizar.
Manten la tabla de slugs siempre actualizada. Cada página nueva necesita una entrada en los tres idiomas. Hazlo rutina: crear página, actualizar tabla de slugs, crear archivos en en/ y es/.
La función t() recurre automáticamente al alemán. Pero las páginas necesitan fallbacks explicitos: si falta una versión ES, redirige a DE. Nunca muestres un 404 solo porque falta una traducción.
Cada página debe incluir etiquetas hreflang para todas las versiones de idioma — incluyendo x-default. Recuerda la URL canonical: siempre apunta a la versión del idioma actual, no al locale por defecto.
Los posts del blog viven en carpetas por idioma: blog/de/, blog/en/, blog/es/. Cada post tiene un campo locale en su frontmatter. Esto permite filtrar listados y etiquetas por idioma.
Define el tipo Locale explícitamente: type Locale = "de" | "en" | "es". Usalo en todas partes en lugar de string. Así TypeScript detecta errores tipográficos en parámetros de locale en tiempo de compilación.
Flujo de trabajo para una nueva página
Crear un nuevo archivo en src/pages/, por ejemplo leistungen.astro. Configurar layout, contenido y meta tags SEO. Probar.
Agregar una nueva entrada a slugTable: { de: "/leistungen", en: "/services", es: "/servicios" }. El mapa de búsqueda inversa se actualiza automáticamente.
Crear archivo en src/pages/en/services.astro. Hacer coincidir el nombre del archivo con el slug inglés. Traducir contenido, establecer locale en "en".
Crear archivo en src/pages/es/servicios.astro. Traducir contenido al español, establecer locale en "es". Probar el selector de idioma — los tres enlaces deben funcionar.
Si la página aparece en la navegación, agregar claves de traducción en los tres archivos JSON. Las etiquetas hreflang se generan automáticamente cuando el componente SEO usa getLocalePathFromCurrent().
Preguntas frecuentes
Frequently Asked Questions
Tres cambios: (1) Agregar el locale en astro.config.mjs, por ejemplo "fr". (2) Agregar una clave fr a cada entrada en la slugTable. (3) Crear un nuevo archivo JSON fr.json con todas las claves de traducción. Las funciones auxiliares y el LanguageSwitcher se adaptan automáticamente porque iteran sobre la lista de locales.
Los posts del blog no usan la tabla de slugs. Viven en carpetas por idioma (content/blog/de/, blog/en/, blog/es/) y tienen un campo locale en su frontmatter. La página de listado del blog filtra por locale, y los posts individuales obtienen su slug del nombre de archivo. El selector de idioma en las páginas del blog enlaza a la página principal del blog del idioma respectivo.
De forma positiva. Los slugs traducidos son una ventaja SEO: /en/about posiciona mejor en resultados de búsqueda en inglés que /en/über-mich. Las etiquetas hreflang aseguran que Google muestre la versión correcta. Y cero sobrecarga en tiempo de ejecución significa mejores Core Web Vitals que las soluciones basadas en plugins.
Si. La tabla de slugs y todos los helpers son funciones puramente de build-time — producen rutas estáticas con output: "static" y funcionan identicamente con SSR. No se necesita JavaScript del lado del cliente: el LanguageSwitcher renderiza etiquetas <a> regulares, no navegación por JavaScript.
Resumen
El enrutamiento i18n nativo de Astro te da la base: carpetas basadas en locale, detección automatica de idioma y prefijos de URL limpios. Lo que le falta — slugs traducidos, un selector de idioma bidireccional y traducciones de interfaz — se puede agregar con un solo archivo TypeScript y tres archivos JSON. Sin plugins, sin sobrecarga de build, control total.
La tabla de slugs es la pieza central: mapea cada ruta a sus tres variantes de idioma y permite la resolución cross-locale en todas las direcciones. Las funciones auxiliares se construyen sobre ella y entregan rutas listas para usar en navegación, enlaces y el selector de idioma. Y la función t() asegura que cada texto de la interfaz aparezca en el idioma correcto.
El resultado: un sitio web trilingüe con URLs limpias y amigables para SEO, un selector de idioma funcional y exactamente cero dependencias adicionales. Todo en aproximadamente 110 líneas de TypeScript.