Ilustración de hreflang en SvelteKit con Paraglide: globo con dos banderas de idioma unidas por flechas curvas

hreflang en SvelteKit con Paraglide: implementación correcta

Un sitio bilingüe sin hreflang correcto es Google adivinando qué versión mostrarle a cada usuario, y adivinando mal la mitad de las veces. En SvelteKit con Paraglide, configurarlo bien lleva un par de horas y elimina el error técnico SEO que más veo en proyectos de clientes. El mismo setup lo corro en ignax.dev y en proyectos con dos a cuatro idiomas. Si querés ver cómo encaja en una estrategia multilingüe más amplia, está el playbook AEO en español. Si venís decidiendo el framework para todo esto, antes mirá la comparación de SvelteKit vs Next.js para SEO.

¿Por qué hreflang importa tanto?

Sin hreflang, en un sitio bilingüe pasan tres cosas:

  1. Contenido duplicado. Google ve /articulos/que-es-aeo y /articles/what-is-aeo con contenido parecido y los marca como duplicados parciales, diluyendo autoridad.
  2. Idioma equivocado mostrado. Un usuario en Asunción busca en español y Google le muestra la versión inglesa porque tiene más autoridad histórica.
  3. Search Console lleno de warnings. Errores hreflang aparecen en el reporte internacional y, si bien no son penalizaciones directas, son señal de problema técnico.

Implementar hreflang bien arregla los tres con una capa pequeña de código. Diagrama de páginas hermanas hreflang con dos tarjetas web unidas por flechas recíprocas con etiquetas de idioma

¿Cómo funciona el setup en Paraglide?

Paraglide es la librería i18n recomendada en SvelteKit en 2026. Su modelo es compile-time: los mensajes se compilan a funciones tipadas, sin runtime overhead. Detecta el locale del usuario y enruta automáticamente.

Configuración típica en svelte.config.js:

import { paraglideVitePlugin } from '@inlang/paraglide-js';

export default {
  // ...
  vite: {
    plugins: [
      paraglideVitePlugin({
        project: './project.inlang',
        outdir: './src/lib/paraglide',
      }),
    ],
  },
};

Y un [[lang=lang]] segment en la ruta para que /articulos/X y /articles/X sean equivalentes con locale distinto. Hasta acá Paraglide te resuelve i18n, pero no resuelve hreflang automáticamente. Eso lo agregás vos.

¿Cómo se ve hreflang correcto en HTML?

En cada página bilingüe, en el <head>, deben aparecer link tags hreflang para cada locale + x-default:

<link rel="alternate" hreflang="en" href="https://ignax.dev/articles/what-is-aeo" />
<link rel="alternate" hreflang="es" href="https://ignax.dev/es/articulos/que-es-aeo" />
<link rel="alternate" hreflang="x-default" href="https://ignax.dev/articles/what-is-aeo" />

Cuatro reglas críticas:

  1. URLs absolutas, siempre. Con protocolo y dominio.
  2. Cada página debe listar a todas sus hermanas idiomáticas. Incluida ella misma. Self-referencing es importante: sin él, Google marca el setup como roto.
  3. x-default presente. Apunta a la versión por defecto (típicamente inglés).
  4. Las URLs hermanas deben listarse mutuamente. Si la página EN lista la ES. La ES debe listar la EN. Reciprocidad estricta.

¿Dónde lo inyecto en SvelteKit?

En el +layout.svelte raíz, calculando las URLs alternativas a partir del path actual y los locales disponibles:

<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import { page } from '$app/state';
  import { availableLanguageTags } from '$lib/paraglide/runtime';

  // Mapa de rutas EN ↔ ES (puede vivir en un módulo aparte)
  const routeMap: Record<string, Record<string, string>> = {
    en: { articles: 'articles', services: 'services' },
    es: { articles: 'articulos', services: 'servicios' },
  };

  function buildAlternateUrl(lang: string) {
    const segments = page.url.pathname.split('/').filter(Boolean);
    // Quita prefijo de idioma actual si existe
    if (segments[0] === 'es') segments.shift();
    // Mapea segmentos a su versión en el locale destino
    const mapped = segments.map(seg => {
      // Mapeá tus rutas conocidas, dejá slugs como están si ya tienen canonicalSlug
      return seg;
    });
    const prefix = lang === 'en' ? '' : `/${lang}`;
    return `https://ignax.dev${prefix}/${mapped.join('/')}`;
  }
</script>

<svelte:head>
  {#each availableLanguageTags as lang}
    <link rel="alternate" hreflang={lang} href={buildAlternateUrl(lang)} />
  {/each}
  <link rel="alternate" hreflang="x-default" href={buildAlternateUrl('en')} />
</svelte:head>

Detalle importante: el mapeo de rutas EN ↔ ES (articlesarticulos, servicesservicios) debe ser explícito. Para artículos individuales, el canonicalSlug del Markdown es el puente: ambas versiones idiomáticas comparten canonicalSlug aunque el slug sea distinto en cada idioma.

¿Cómo lo agrego al sitemap.xml?

El sitemap multilingüe usa el namespace xhtml:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:xhtml="http://www.w3.org/1999/xhtml">
  <url>
    <loc>https://ignax.dev/articles/what-is-aeo</loc>
    <xhtml:link rel="alternate" hreflang="en" href="https://ignax.dev/articles/what-is-aeo" />
    <xhtml:link rel="alternate" hreflang="es" href="https://ignax.dev/es/articulos/que-es-aeo" />
    <xhtml:link rel="alternate" hreflang="x-default" href="https://ignax.dev/articles/what-is-aeo" />
    <lastmod>2026-05-27</lastmod>
  </url>
  <!-- ... -->
</urlset>

En SvelteKit, generalo desde una ruta +server.ts que recorre el registry Markdown:

// src/routes/sitemap.xml/+server.ts
import { listAllContent } from '$lib/content/loader';

export async function GET() {
  const entries = await listAllContent();
  // Agrupar por canonicalSlug para juntar pares EN/ES
  const groups = new Map<string, typeof entries>();
  for (const e of entries) {
    if (!groups.has(e.canonicalSlug)) groups.set(e.canonicalSlug, []);
    groups.get(e.canonicalSlug)!.push(e);
  }

  const urls = [...groups.values()].map(group => {
    const en = group.find(g => g.lang === 'en');
    const es = group.find(g => g.lang === 'es');
    const primary = en ?? es!;
    const links = group.map(g => 
      `<xhtml:link rel="alternate" hreflang="${g.lang}" href="${buildUrl(g)}" />`
    ).join('');
    return `<url>
      <loc>${buildUrl(primary)}</loc>
      ${links}
      ${en ? `<xhtml:link rel="alternate" hreflang="x-default" href="${buildUrl(en)}" />` : ''}
      <lastmod>${primary.updatedAt}</lastmod>
    </url>`;
  }).join('');

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:xhtml="http://www.w3.org/1999/xhtml">
  ${urls}
</urlset>`;

  return new Response(xml, { headers: { 'content-type': 'application/xml' } });
}

¿Qué errores comunes evitar?

Los que más aparecen en auditorías:

  1. URLs relativas en hreflang. Google las acepta a veces y otras no. Siempre absolutas.
  2. Falta de self-referencing. Cada página debe listarse a sí misma en su hreflang. Sin esto. El grupo se rompe.
  3. No reciprocidad. Si EN apunta a ES pero ES no apunta a EN, Google ignora el cluster.
  4. es-MX o es-ES cuando el contenido es genérico. Subdivide por región solo si servís contenido distinto.
  5. hreflang inyectado por JavaScript del lado del cliente. Debe estar en el HTML SSR inicial. SvelteKit con SSR lo resuelve naturalmente: verificá con curl.

¿Cómo verifico que funciona?

Tres maneras:

  1. Google Search Console → Internacional → hreflang. Reporta errores y warnings con detalle.
  2. curl -s https://tusitio.com/es/articulos/X | grep hreflang. Confirma que el HTML servidor-renderizado incluye los tags.
  3. Hreflang Tags Testing Tool de TechnicalSEO.com. Te verifica reciprocidad, formato y x-default. Gratis.

Adicionalmente, la documentación oficial de Google sobre hreflang es la fuente canónica para dudas edge.

¿Cuándo no necesitás hreflang?

Si tu sitio es monolingüe, no lo necesitás. Si tenés contenido idéntico replicado en dos URLs distintas (mismo idioma, dos dominios), lo que necesitás es <link rel="canonical">, no hreflang. Si tu contenido en EN y ES son muy distintos (no traducciones, sino temas distintos), tampoco hreflang, son páginas distintas, no alternativas.

¿Y los archivos llms.txt y robots.txt en setup multilingüe?

Para AEO bilingüe completo, los archivos llms.txt y robots.txt viven en la raíz del dominio, no se duplican por idioma. Pero el contenido referenciado en llms.txt sí puede incluir URLs de ambos idiomas. Detalle en llms.txt explicado.

¿Cómo manejo URLs de slug distinto entre idiomas?

El caso más común en bilingüe EN/ES: los slugs son distintos. articles/what-is-aeo (EN) ↔ articulos/que-es-aeo (ES). El puente es el canonicalSlug compartido en el front-matter Markdown.

# src/content/articles/en/what-is-aeo.md
slug: what-is-aeo
canonicalSlug: what-is-aeo
lang: en

# src/content/articles/es/que-es-aeo.md
slug: que-es-aeo
canonicalSlug: what-is-aeo   # mismo que en EN
lang: es

Cuando construyas hreflang en +layout.svelte, agrupás por canonicalSlug:

// Buscás la entrada actual por su slug y obtenés el canonicalSlug
const current = await loadEntryByPath(currentPath);
const siblings = await listEntriesBySlug(current.canonicalSlug);
// siblings = [{lang:'en', slug:'what-is-aeo', ...}, {lang:'es', slug:'que-es-aeo', ...}]

for (const s of siblings) {
  // Construir URL absoluta según lang y slug
  const url = buildUrl(s);
  // Inyectar hreflang
}

Esto evita el problema de adivinar slugs hermanos por convención, todo viene del registry.

¿Cómo verifico que el sitemap multilingüe está completo?

Tres checks rápidos:

  1. curl https://tusitio.com/sitemap.xml | grep hreflang | wc -l: debe devolver el doble (o triple) de URLs únicas, porque cada URL aparece con varios hreflang.
  2. Google Search Console → Sitemaps → submit. Verificá que se procesa sin errores.
  3. Bing Webmaster Tools → Sitemaps. Verificá que Bing también lo lee.

Para sitios grandes (>50k URLs), dividir en sitemap index con varios sitemap children es buena práctica. Cada child <50k URLs. Si todavía no tenés el sitemap dinámico armado, repasá la configuración SEO completa de Cloudflare Pages.

Si querés que te implemente todo el setup multilingüe (Paraglide + hreflang + sitemap + llms.txt + AEO bilingüe) escribime a [email protected]. El servicio completo está en SEO multilingüe.

Preguntas frecuentes

¿Hace falta hreflang si solo tengo dos idiomas?

Sí. Hreflang le dice a Google y Bing qué versión idiomática mostrar a cada usuario. Sin hreflang, Google puede mostrar tu versión en inglés a un usuario hispanohablante en Paraguay, perdiendo el match. Con dos idiomas (EN/ES) implementar hreflang lleva 2 horas y arregla un problema real. Con 3+ idiomas es directamente obligatorio.

¿Qué pasa si pongo `es` vs `es-PY` vs `es-ES`?

Si tu contenido es genérico hispanohablante, usá `es` solo. Si tenés variantes regionales (precios en PYG, modismos rioplatenses), usá `es-PY` y `es-ES` por separado y serví contenido distinto. No tiene sentido marcar `es-PY` si tu contenido es genérico, Google ignora el region tag cuando no hay diferencia real. La regla: solo subdivido por región si el contenido se ajusta para esa región.

¿Qué es x-default y cuándo lo necesito?

x-default es el hreflang especial que indica qué página servir cuando ningún match idiomático aplica (usuario en idioma no listado, o no se detecta idioma). Convención: apuntá x-default a tu versión inglesa por defecto. Sin x-default, Google decide solo y a veces decide mal. Es una línea más en el HTML y resuelve casos edge que pasan más seguido de lo que parece.

¿Las URLs en hreflang deben ser absolutas o relativas?

Absolutas siempre. Las URLs relativas en hreflang son una causa común de errores silenciosos que Google reporta meses después en Search Console. Usá la URL completa con protocolo: `https://tusitio.com/es/articulos/slug`. En SvelteKit con Paraglide construilas dinámicamente en el +layout.svelte raíz a partir de la URL actual y los locales disponibles.

¿Pongo hreflang solo en el HTML o también en sitemap.xml?

Ambos. El HTML con `<link rel='alternate' hreflang='X' href='Y'>` cubre el crawl normal. El sitemap.xml con `<xhtml:link rel='alternate' hreflang='X' href='Y'>` cubre el descubrimiento masivo y es más eficiente para sitios grandes. Google usa los dos como señales redundantes. Implementar ambos en SvelteKit es trivial; el sitemap se genera del mismo registry que las páginas.

¿Cómo verifico que hreflang funciona?

Tres maneras: 1) Google Search Console → Reporte de hreflang internacional, muestra errores y warnings; 2) extensiones tipo SEO Meta in 1 Click que muestran los hreflang del HTML; 3) `curl -s URL | grep hreflang` para confirmar que se renderiza server-side. En SvelteKit con SSR esto debe verse en el HTML inicial, no inyectado por JS.