Enrutamiento de URLs trilingüe con mapeo de slugs en Astro
12 min read

Astro i18n sin plugins: Guía de mapeo de slugs

#Astro #i18n #Routing

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.

Astro Docs Documentación oficial

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.

3
Idiomas
12+
Rutas
106
Páginas
110
Líneas de Código
0
Plugins

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.

Flujo de resolución de rutas para la configuración i18n
Configuración de Astro

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.

Tabla de mapeo de slugs con asignación trilingüe
Tabla de slugs

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.

Selector de idioma con lógica de resolución
Funciones auxiliares

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.

Archivos JSON de traducción con mapeo de claves
JSONs de traducción

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.currentLocale proporciona 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:

  1. 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.

  2. 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.

  3. 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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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

Locale por defecto primero

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.

Consistencia de slugs

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/.

Estrategia de fallback

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.

SEO con hreflang

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.

Content Collections

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.

Seguridad de tipos

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 la página alemana

Crear un nuevo archivo en src/pages/, por ejemplo leistungen.astro. Configurar layout, contenido y meta tags SEO. Probar.

Actualizar la tabla de slugs

Agregar una nueva entrada a slugTable: { de: "/leistungen", en: "/services", es: "/servicios" }. El mapa de búsqueda inversa se actualiza automáticamente.

Crear la versión inglesa

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 la versión española

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.

Actualizar navegación y hreflang

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

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.