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

Astro i18n sin plugins: Sitios web trilingues con 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 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.

Astro Docs Documentacion oficial

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.

3
Idiomas
0
Plugins
1
Tabla de slugs

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.

Flujo de resolucion de rutas para la configuracion i18n
Configuracion de Astro

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.

Tabla de mapeo de slugs con asignacion trilingue
Tabla de slugs

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.

Selector de idioma con logica de resolucion
Funciones auxiliares

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.

Archivos JSON de traduccion con mapeo de claves
JSONs de traduccion

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

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

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

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

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

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

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

Locale por defecto primero

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.

Consistencia de slugs

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

Estrategia de fallback

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.

SEO con hreflang

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.

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 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 la pagina 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 busqueda inversa se actualiza automaticamente.

Crear la version inglesa

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 la version espanola

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.

Actualizar navegacion y hreflang

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

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.