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 quizas no necesitas. Y si quieres tener el control total? Y si necesitas URLs traducidas — /ueber-mich en aleman, /about en ingles, /sobre-mi en espanol — sin nada opaco de por medio?
Exactamente eso es lo que construi para este sitio web: trilingue, usando el enrutamiento i18n nativo de Astro y una unica 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 multiples idiomas — con control total sobre los patrones de URL y la logica de fallback.
El problema con los plugins de i18n
Los plugins de internacionalizacion resuelven un problema real, pero a menudo vienen con suposiciones que no encajan en todos los proyectos. Algunos generan carpetas automaticamente, otros exigen convenciones especificas de nombres de archivo. La mayoria no traduce URLs — terminas con /en/ueber-mich en lugar de /en/about. Y cuando necesitas depurar un problema, te encuentras con codigo abstraido que no escribiste.
El enrutamiento i18n nativo de Astro te da la base: prefijos basados en locale, deteccion del locale por defecto y negociacion de idioma. Lo que no proporciona es la traduccion de slugs — y ahi es precisamente donde entra nuestra solucion personalizada.
Vista general de la arquitectura
El concepto es sencillo: el aleman es el locale por defecto y no lleva prefijo en la URL. El ingles obtiene /en/, el espanol /es/. Cada pagina tiene un slug semanticamente apropiado en cada idioma — sin parametros 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. Asi el aleman no tiene prefijo — /kontakt en lugar de /de/kontakt. Ingles y espanol obtienen automaticamente /en/ y /es/ como prefijos. Esa es la unica configuracion que Astro necesita.
Un archivo TypeScript central (src/i18n/utils.ts) contiene una tabla que mapea cada ruta de pagina a sus tres variantes de idioma. /kontakt se convierte en /contact (EN) y /contacto (ES). Un mapa de busqueda inversa permite la resolucion en ambas direcciones — desde cualquier slug hacia cualquier slug destino.
Dos funciones principales usan la tabla: getLocalePath() genera rutas conscientes del locale para navegacion 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 funcion t(locale, key) devuelve la cadena traducida, con fallback automatico al aleman. Simple, type-safe y sin sobrecarga en tiempo de ejecucion.
La configuracion i18n en Astro
El punto de partida es astro.config.mjs. Aqui le dices a Astro que idiomas soporta tu sitio y como 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 lineas de configuracion, Astro entiende que:
- Las paginas en
src/pages/pertenecen a la version alemana (sin prefijo) - Las paginas en
src/pages/en/son la version inglesa (prefijo/en/) - Las paginas en
src/pages/es/son la version espanola (prefijo/es/) Astro.currentLocaleproporciona automaticamente el idioma actual en cada pagina
Lo que Astro no hace: no traduce slugs. /en/kontakt es una ruta inglesa valida para Astro — pero semanticamente sin sentido. Aqui comienza nuestro trabajo personalizado.
La tabla de slugs
El corazon de la solucion 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: '/ueber-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 facil de leer, facil de extender y facil de depurar.
El mapa slugToEntry es el truco clave: registra cada slug de cada idioma como clave y apunta a la misma entrada. Asi puedes buscar cualquier slug — /contact (EN), /contacto (ES) o /kontakt (DE) — y encontrar la entrada para resolver el slug de cualquier otro idioma. La resolucion funciona en todas las direcciones.
El helper getLocalePath()
La primera funcion central genera rutas conscientes del locale. Se usa en la navegacion, enlaces internos y en cualquier lugar donde necesites una ruta para un idioma especifico.
/**
* 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, '/ueber-mich')}>
{t(locale, 'nav.about')}
</a>
<a href={getLocalePath(locale, '/leistungen')}>
{t(locale, 'nav.services')}
</a>
</nav>
<!-- Resultados por locale:
DE: /kontakt, /ueber-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 funcion encuentra la entrada y devuelve el slug correcto para el idioma destino. Esto hace que los componentes de navegacion sean agnosticos al idioma: los escribes una vez con slugs alemanes, y la funcion traduce automaticamente.
El selector de idioma
El selector de idioma es la pieza que une todo. Necesita tomar la URL actual, determinar que pagina 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 funcion 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 aleman no hay prefijo, para EN/ES se antepone el codigo de locale.
Los archivos JSON de traduccion
Ademas de los slugs, tambien necesitamos textos de interfaz traducidos: etiquetas de navegacion, texto de botones, etiquetas de formularios, enlaces del footer. Estos viven en tres archivos JSON con claves identicas.
{
"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 anidacion, sin namespaces, sin interpolacion. Un objeto plano clave-valor por idioma y una funcion que devuelve el valor. El fallback a aleman asegura que las traducciones faltantes nunca produzcan una cadena vacia — siempre ves al menos el texto aleman, y si ese tambien falta, la propia clave. Invaluable para depuracion.
La estructura de archivos
Aqui tienes una vision 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 configuracion del framework — tres lineas que le dicen a Astro como mapear rutas a idiomas.
- src/i18n/utils.ts
Contiene la tabla de slugs, el mapa de busqueda inversa, getLocalePath(), getLocalePathFromCurrent(), t() y getTranslations(). Un solo archivo de ~110 lineas que impulsa todo el sistema i18n.
- src/i18n/locales/*.json
Tres archivos JSON (de.json, en.json, es.json) con claves identicas y valores especificos por idioma. Contienen todos los textos de la interfaz: navegacion, botones, formularios, mensajes de error, metadatos SEO.
- src/pages/en/*.astro y src/pages/es/*.astro
Para cada pagina existe una version inglesa y espanola 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 navegacion usan getLocalePath() y t() con Astro.currentLocale para renderizar enlaces y etiquetas segun el idioma. Sin duplicacion — 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)
│ ├── ueber-mich.astro ← Sobre mi DE (/ueber-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. solucion propia
| Aspecto | Basado en plugin | Tabla de slugs propia Recommended |
|---|---|---|
| Tamano del bundle | Dependencia JS adicional | Cero sobrecarga en tiempo de ejecucion |
| 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 + configuracion | Un archivo con ~110 lineas |
| Traduccion de slugs | A menudo no soportada | Funcionalidad central |
| Depuracion | Entender internos del plugin | Tu propio codigo, transparencia total |
| Actualizaciones | Depende de los mantenedores del plugin | Sin dependencias externas |
| TypeScript | Varia segun el plugin | Integracion nativa con TypeScript |
Aspectos SEO: hreflang y Canonical
Las URLs traducidas son solo la mitad del trabajo. Los motores de busqueda necesitan saber que paginas estan 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 version de idioma de una pagina referencia a todas las demas versiones. La etiqueta x-default apunta a la version alemana como fallback. Asi Google sabe que /kontakt, /en/contact y /es/contacto son traducciones de la misma pagina — y muestra al usuario la version correcta en los resultados de busqueda. Para profundizar en como los datos estructurados mejoran la visibilidad en buscadores, consulta nuestra guia sobre JSON-LD y datos estructurados.
Buenas practicas
Construye siempre primero la version alemana (por defecto) completa. Las versiones EN/ES son copias con contenido traducido y slugs ajustados. Asi tienes un producto funcional antes de internacionalizar.
Manten la tabla de slugs siempre actualizada. Cada pagina nueva necesita una entrada en los tres idiomas. Hazlo rutina: crear pagina, actualizar tabla de slugs, crear archivos en en/ y es/.
La funcion t() recurre automaticamente al aleman. Pero las paginas necesitan fallbacks explicitos: si falta una version ES, redirige a DE. Nunca muestres un 404 solo porque falta una traduccion.
Cada pagina debe incluir etiquetas hreflang para todas las versiones de idioma — incluyendo x-default. Recuerda la URL canonical: siempre apunta a la version 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 explicitamente: type Locale = "de" | "en" | "es". Usalo en todas partes en lugar de string. Asi TypeScript detecta errores tipograficos en parametros de locale en tiempo de compilacion.
Flujo de trabajo para una nueva pagina
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 busqueda inversa se actualiza automaticamente.
Crear archivo en src/pages/en/services.astro. Hacer coincidir el nombre del archivo con el slug ingles. Traducir contenido, establecer locale en "en".
Crear archivo en src/pages/es/servicios.astro. Traducir contenido al espanol, establecer locale en "es". Probar el selector de idioma — los tres enlaces deben funcionar.
Si la pagina aparece en la navegacion, agregar claves de traduccion en los tres archivos JSON. Las etiquetas hreflang se generan automaticamente 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 traduccion. Las funciones auxiliares y el LanguageSwitcher se adaptan automaticamente 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 pagina de listado del blog filtra por locale, y los posts individuales obtienen su slug del nombre de archivo. El selector de idioma en las paginas del blog enlaza a la pagina principal del blog del idioma respectivo.
De forma positiva. Los slugs traducidos son una ventaja SEO: /en/about posiciona mejor en resultados de busqueda en ingles que /en/ueber-mich. Las etiquetas hreflang aseguran que Google muestre la version correcta. Y cero sobrecarga en tiempo de ejecucion 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 estaticas con output: "static" y funcionan identicamente con SSR. No se necesita JavaScript del lado del cliente: el LanguageSwitcher renderiza etiquetas <a> regulares, no navegacion por JavaScript.
Resumen
El enrutamiento i18n nativo de Astro te da la base: carpetas basadas en locale, deteccion 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 resolucion cross-locale en todas las direcciones. Las funciones auxiliares se construyen sobre ella y entregan rutas listas para usar en navegacion, enlaces y el selector de idioma. Y la funcion t() asegura que cada texto de la interfaz aparezca en el idioma correcto.
El resultado: un sitio web trilingue con URLs limpias y amigables para SEO, un selector de idioma funcional y exactamente cero dependencias adicionales. Todo en aproximadamente 110 lineas de TypeScript.