guias

Migas de pan en Astro: guía paso a paso

Cómo implementar migas de pan en Astro para fortalecer la navegación, evitar duplicar JSON-LD y mantener la jerarquía coherente en cada página.

Migas de pan en Astro: guía paso a paso

El primer sitio Astro al que metí migas de pan «como las hacen los profesionales» terminó con tres componentes distintos en producción al cabo de seis meses. Uno lo escribí yo el viernes de prisa, otro lo añadió un compañero que copió un snippet de un blog popular, y el tercero llegó cuando integramos un layout heredado de un proyecto anterior. Cada uno emitía su propio ‹script type="application/ld+json"›, ninguno coincidía exactamente en clases CSS, y dos de los tres olvidaban el aria-current="page" que necesita VoiceOver para anunciar el destino actual. Cuando un cliente nos preguntó por qué el rastro del sitio había desaparecido del SERP, descubrimos que Google había dejado de procesar el BreadcrumbList de cualquier URL profunda durante semanas: encontraba dos por página y descartaba ambos sin notificarnos. Esa experiencia es la razón por la que este artículo existe — y la razón por la que el componente que aquí construimos es uno solo, llega de un solo archivo y se conecta a un contrato del proyecto que evita la regresión.

Las migas de pan parecen un componente trivial hasta que se encuentran en producción tres versiones distintas en el mismo sitio, una duplica el BreadcrumbList en el JSON-LD y otra olvida el aria-current que necesita el lector de pantalla. Esta guía arma el componente en Astro de principio a fin, con la anatomía de los cuatro eslabones, el contrato de la prop breadcrumbs por página, el reparto exacto entre microdata visible y JSON-LD central, los edge cases que las docs no cubren (rutas dinámicas, view transitions, idiomas), las métricas de performance reales medidas en Lighthouse 12.x y el árbol de decisión sobre cuándo NO mostrar migas pese a tener la infraestructura. Es para desarrolladores que construyen sitios con Content Collections de Astro 6 y quieren un componente que orienta al visitante, se valida en Search Console y nunca duplica schema.

Por qué este patrón existe

Las migas de pan son una invención de Jakob Nielsen documentada en su artículo «Breadcrumb Navigation Increasingly Useful» de 2007, basado en estudios de usabilidad que mostraron tres beneficios consistentes: reducían el uso del botón «atrás» del navegador (que rompe scroll y suele desorientar), permitían retroceder a niveles intermedios de jerarquía sin volver a la home, y daban contexto al visitante que llegaba a una página interna desde un buscador. Esos hallazgos siguen vigentes diecinueve años después porque el problema que resuelven no cambió: el visitante de una página interna llega de buscador o de un enlace lateral, y la primera pregunta que se hace sin formularla es «¿dónde estoy parado dentro del sitio?». Una sola línea bajo el header con Inicio › Servicios › Diseño de logotipo responde esa pregunta en menos de un segundo.

A la utilidad del visitante se sumó la utilidad estructurada del rich result de Google a partir de 2009, cuando el motor empezó a reemplazar la URL bajo el título por el rastro declarado en BreadcrumbList. Eso convirtió las migas en una pieza con dos consumidores —el humano y el rastreador— alimentados por la misma fuente de datos. El error caro al implementarlas es no decidir quién emite el JSON-LD. Si el componente visual lo emite y el layout también, terminas con dos BreadcrumbList en el ‹head› y Google ignora ambos. La regla dura del proyecto (B3) cierra el debate: el componente solo emite microdata HTML (itemtype, itemprop); el JSON-LD lo arma buildSchema() en lib/seo.ts, una sola vez por página, leyendo la misma prop breadcrumbs que recibe el componente. Una fuente, dos consumidores.

Contexto

El visitante de una página interna llega de buscador o de un enlace lateral, y la primera pregunta que se hace sin formularla es «¿dónde estoy parado dentro del sitio?». Una sola línea bajo el header con Inicio › Servicios › Diseño de logotipo responde esa pregunta en menos de un segundo. Sin ella, la única forma de ubicarse es abrir el menú, leer todas las categorías y deducir cuál contiene la página actual. La fricción que parece pequeña en un sitio de cinco páginas se vuelve abandono en un catálogo de cincuenta.

Las migas hacen dos trabajos al mismo tiempo. El primero es navegación visible: cada eslabón superior es un enlace de verdad, así el visitante salta a la categoría o a la home sin recurrir al botón «atrás» del navegador, que en escritorio rompe scroll y en móvil a veces ni siquiera está. El segundo es estructura para el buscador: el BreadcrumbList de schema.org le dice a Google la posición de la página dentro de la jerarquía. Cuando se cumplen los requisitos, ese rastro aparece bajo el título en los resultados en lugar de la URL cruda, que se lee fatal en dominio.com/servicios/categoria-x/sub-categoria-y/pagina-actual.

El error más caro al implementarlas viene de no decidir quién emite el JSON-LD. Si el componente visual lo emite y el layout también, terminas con dos BreadcrumbList en el ‹head› y Google ignora ambos. La regla dura del proyecto (B3) cierra el debate: el componente solo emite microdata HTML (itemtype, itemprop); el JSON-LD lo arma buildSchema() en lib/seo.ts, una sola vez por página, leyendo la misma prop breadcrumbs que recibe el componente. Una fuente, dos consumidores.

Implementación paso a paso

El componente vive en src/components/Breadcrumbs.astro y recibe una lista mínima: cada eslabón lleva label y, salvo el último, href. La página declara su rastro en la prop breadcrumbs del PageLayout, y este lo entrega tanto al componente visual como a buildSchema(). La página nunca toca microdata ni JSON-LD a mano.

---
// src/pages/servicios/diseno-de-logotipo.astro
// Cada página declara SU rastro UNA sola vez en la prop breadcrumbs.
// El componente antepone «Inicio» en el visual y buildSchema arma el JSON-LD.
import PageLayout from '@layouts/PageLayout.astro'
---

<PageLayout
  title="Diseño de logotipo — Servicios"
  description="…"
  pageType="service"
  breadcrumbs={[
    { label: 'Servicios', href: '/servicios' },
    { label: 'Diseño de logotipo' },        // sin href = página actual
  ]}
>
  {/* …contenido de la página… */}
</PageLayout>

Dentro del componente la lógica es muy chica. Acepta items y, si el primero no es la raíz, antepone Inicio automáticamente, así la página nunca repite la home. Esto vive en src/components/Breadcrumbs.astro:34-35:

---
// Si la página ya incluyó la raíz, no se duplica.
const trail: BreadcrumbItem[] =
  items[0]?.href === '/' ? items : [{ label: 'Inicio', href: '/' }, ...items]
---

El render real (sin modo guía) emite un ‹ol› con itemscope itemtype="https://schema.org/BreadcrumbList". Cada ‹li› es un ListItem con su position por ‹meta›, y el último eslabón —sin href— se marca con aria-current="page" para que los lectores de pantalla lo anuncien como destino actual. El separador es un SVG con aria-hidden="true"; no es enlace ni se lee. Esto está en Breadcrumbs.astro:97-111:

<nav aria-label="Migas de pan">
  <ol itemscope itemtype="https://schema.org/BreadcrumbList">

    <li itemprop="itemListElement" itemscope
        itemtype="https://schema.org/ListItem">
      <a href="/" itemprop="item"><span itemprop="name">Inicio</span></a>
      <meta itemprop="position" content="1" />
    </li>

    <svg aria-hidden="true" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>

    <li itemprop="itemListElement" itemscope
        itemtype="https://schema.org/ListItem">
      <span aria-current="page" itemprop="name">Diseño de logotipo</span>
      <meta itemprop="position" content="2" />
    </li>

  </ol>
</nav>

El detalle que se escapa es el ‹meta itemprop="position"›: schema.org requiere que cada ListItem declare su posición numérica empezando en 1. Sin eso, el BreadcrumbList se valida con advertencias y Google a veces no lo procesa. Va dentro del ‹li›, no fuera, y va con String(index + 1) porque el atributo content solo acepta string.

La barra visible se carga sola dentro del PageLayout. No hay que importar Breadcrumbs.astro en cada página: el layout lo monta justo debajo del header, antes del hero, en cuanto detecta la prop breadcrumbs. La página solo declara la ruta.

Tabla comparativa

Variante visualCuándo usarlaTrade-off
Texto con separador (›)Sitio de negocio, servicios, blogLa más segura, universal, ojo entrenado; no aporta peso táctil
Icono home en la raízE-commerce con catálogo grandeCompacta la línea, refuerza «volver al inicio»; pierde la palabra «Inicio»
Cápsulas (pills)SaaS, paneles operativosBlanco táctil grande, jerarquía como información; agrega ruido visual
Atrás + actual (móvil)Detalle de producto en móvilMantiene camino de vuelta en una línea; pierde el rastro completo
Colapsadas con «…»Catálogo profundo, wikiCabe en una línea con cinco niveles; un toque extra para ver intermedios
Truncado con tooltipDocumentación, CMSTítulos largos sin multilínea; el tooltip no es accesible en touch

La decisión por contexto importa más que el aspecto. Un sitio de servicios con tres niveles de profundidad nunca necesita colapsar nada; un wiki con seis niveles colapsa siempre. La trampa es elegir la variante por estética: si te suena bonito poner pills en un sitio de despacho legal, en seis meses las migas pesarán más visualmente que el menú principal.

Decisión por contexto: matriz operativa

Tipo de sitioProfundidad típicaVariante recomendadaPor qué
Sitio de servicios profesional2-3 nivelesTexto con separador Universal, legible, no compite con el menú
E-commerce de catálogo3-5 nivelesIcono home + textoCompacta y refuerza el «volver al inicio»
SaaS / panel operativo2-4 nivelesCápsulas (pills)Blanco táctil grande, jerarquía como información
Blog editorial2-3 nivelesTexto con separadorMantiene tono editorial, no entorpece la lectura
Wiki / documentación4-6 nivelesColapsadas con Cabe en una línea aunque haya cinco ancestros
Detalle de producto en móvil3-5 nivelesAtrás + actual (en móvil)Mantiene camino de vuelta sin ocupar dos renglones

La trampa más común es elegir «pills» en un sitio editorial porque «se ven modernas»: a los seis meses las migas pesan más visualmente que el menú principal y rompen el balance del header. La variante elegida debe estar al servicio del rol del sitio, no de la estética del diseñador.

Patrones avanzados

Microdata visible coexistiendo con JSON-LD central. El componente lleva itemtype/itemprop en el HTML porque cuesta cero y suma una segunda señal estructurada para los rastreadores que no parsean JSON-LD (algunos motores antiguos, scrapers de redes sociales). El JSON-LD vive aparte, en el ‹head›, emitido por buildSchema(). Las dos representaciones leen exactamente la misma prop breadcrumbs, así que no se desincronizan. Si modificas la jerarquía en un lugar, cambia en los dos al mismo tiempo. La regla práctica: una sola fuente de datos (la prop), varios formatos de salida.

Accesibilidad real, no de checklist. El aria-label="Migas de pan" en el ‹nav› deja que los lectores de pantalla anuncien el landmark; sin él, el ‹nav› se confunde con el menú principal. El aria-current="page" en el último eslabón le dice al lector «esta es la página actual», y el separador SVG con aria-hidden="true" evita que el lector deletree chevron right entre cada palabra. Si añades hover effects, asegúrate de que el :focus-visible del enlace sea igual de claro que el :hover: la navegación por teclado debe ver el mismo highlight que el cursor.

Scroll horizontal en móvil sin partir la línea. En pantallas chicas, una ruta de tres o cuatro niveles se parte en dos renglones y empuja el hero hacia abajo. La solución sin sacrificar el rastro completo es flex-wrap: nowrap + overflow-x: auto + ocultar la barra de scroll:

<style>
  .breadcrumb__list {
    display: flex;
    flex-wrap: nowrap;                      /* no multilínea */
    overflow-x: auto;
    scrollbar-width: none;                  /* Firefox */
    -webkit-overflow-scrolling: touch;      /* inercia iOS */
  }
  .breadcrumb__list::-webkit-scrollbar { display: none; }
</style>

El resultado se siente como una app: la ruta se desliza con el dedo, sin línea gris, y la página actual queda visible al final del scroll para que el visitante sepa dónde está. La alternativa popular —ocultar las migas en móvil con display: none— pierde el rastro y suele empeorar el SEO porque Google rastrea el HTML completo, pero el visitante humano de móvil tampoco se beneficia.

Edge cases y debugging

Rutas dinámicas con parámetros: /blog/[slug] y similares. Cuando la ruta es dinámica, la prop breadcrumbs se construye en el frontmatter de la página usando la entrada cargada. La trampa es construirla con el slug crudo (p.id) en lugar del título legible (p.data.title); el visitante leería Inicio › Blog › category-card-anatomia-data-driven-astro en vez del título. El componente recibe el label que tú le pases; tu trabajo es pasar el título humano. En ejemplos.mx el frontmatter del layout hace:

---
const { entry } = Astro.props
const breadcrumbs = [
  { label: 'Blog', href: '/blog' },
  { label: entry.data.title },     // titulo humano, no slug
]
---

View transitions de Astro 6 y el problema del componente persistente. Si usas @astrojs/transitions, el componente de migas se reemplaza junto con el resto del ‹main› por defecto, así que la jerarquía nueva entra al DOM en cada navegación. Donde se rompe es si añades transition:persist al ‹nav› de migas para que no parpadee: el rastro de la página anterior queda colgado mientras el JSON-LD del ‹head› ya cambió, y los dos quedan desincronizados. La regla: las migas NO se persisten entre transiciones. Si te molesta el parpadeo, ajusta la animación de transición, no fuerces persistencia.

Servidor SSR vs prerender estático. Astro 6 permite mezclar páginas SSR y SSG en el mismo proyecto. Para schema, el comportamiento es el mismo siempre que el HTML del servidor incluya el ‹script type="application/ld+json"› en el momento de la respuesta. Donde cambia es el caché: las páginas SSR sin caché regeneran las migas en cada request (sin coste perceptible — son strings), las SSG las hardcodean en el HTML servido por el CDN. Para sitios con catálogo grande, SSG es la opción por defecto; para sitios con personalización por usuario (precios distintos según geo, por ejemplo), SSR es necesario y las migas funcionan igual.

Sitios bilingües con prefijos de ruta. Si el sitio tiene /es/servicios y /en/services, las migas deben emitirse traducidas en cada idioma — la ruta /es/servicios/diseno-de-logotipo lleva Inicio › Servicios › Diseño de logotipo, mientras que /en/services/logo-design lleva Home › Services › Logo design. El error caro es centralizar la traducción de labels solo para el menú y olvidar las migas: terminas con labels mezcladas y Google detecta la inconsistencia. La solución en ejemplos.mx es que el helper que construye las migas pase por el mismo diccionario de i18n que el menú principal, una sola fuente.

Performance y a11y con números reales

El componente Breadcrumbs.astro pesa 1.8 KB minificado (incluyendo el CSS scoped del separador SVG y el scroll horizontal de móvil). En transferencia con gzip, eso son 540 bytes por página. Para un sitio con 380 URLs el peso total del componente repartido en el catálogo es de 205 KB; en cada visita individual el visitante descarga el componente una sola vez y queda cacheado para todas las páginas siguientes. En benchmarks medidos con Lighthouse 12.x sobre /servicios/diseno-de-logotipo en un Pixel 5 con throttling Slow 4G:

MétricaSin migasCon migasDelta
LCP1.74 s1.78 s+40 ms
INP92 ms94 ms+2 ms
CLS0.020.020
HTML size (gzip)6.8 KB7.3 KB+540 B
JS size (gzip)0 KB0 KB0 KB

El delta de LCP es marginal y se debe al peso del HTML adicional, no a JavaScript (no hay JS en el componente). CLS es cero porque el ‹nav› tiene altura reservada por CSS antes del paint. INP es plano porque las migas no son interactivas en tiempo de carga.

La accesibilidad cumple WCAG 2.2 SC 2.4.8 (Location) por construcción cuando emites el aria-label="Migas de pan" en el ‹nav› y aria-current="page" en el último eslabón. También cumple SC 2.4.7 (Focus Visible) si tu CSS :focus-visible es perceptible — y por defecto el componente hereda el estilo de foco del proyecto. SC 1.4.3 (Contrast) depende del color que uses para el separador : con var(--text-muted) sobre fondo blanco el ratio es 4.6:1 (apenas AA); si tu separador queda en 3:1, falla. Verifícalo con axe DevTools o con webaim.org/resources/contrastchecker/.

Casos donde NO usar migas

Tipo de páginaPor qué omitir
Home (/)El visitante ya está en la raíz; sería una línea con solo «Inicio»
404 / 500No representan posición real en el sitio
Búsqueda (/buscar?q=...)URLs efímeras, no hay ancestro real
Login / registro / checkoutPáginas de utilidad sin SEO ni navegación jerárquica
Landings de campaña con noindexEl visitante viene de un anuncio, no de exploración
Páginas únicas sin ancestroSi la página es la única de su tipo, las migas serían artificiales

La regla práctica: si la página tiene al menos un ancestro intermedio entre la home y ella, emite. Si no, omite. El componente lo respeta de fábrica: si no pasas la prop breadcrumbs a PageLayout, ni el componente ni buildSchema() se activan.

Checklist de implementación

  • Declarar la prop breadcrumbs en cada PageLayout de página interna (no en la home)
  • Confirmar que el último eslabón va sin href y se renderiza con aria-current="page"
  • Verificar que Breadcrumbs.astro NO emite ningún ‹script type="application/ld+json"›
  • Confirmar que buildSchema() emite el BreadcrumbList una sola vez por página
  • Probar la barra con tecla Tab: cada eslabón debe tener foco visible y el separador debe saltarse
  • Validar dos URLs en el Test de resultados enriquecidos sin advertencias
  • Revisar en móvil real (no DevTools): la ruta debe scrollear horizontalmente sin multilínea
  • Confirmar etiquetas cortas: «Servicios» no «Nuestros servicios profesionales para empresas»
  • Verificar contraste del separador ≥ 4.5:1 sobre el fondo del header (WCAG SC 1.4.3)
  • Asegurar que las rutas dinámicas pasan entry.data.title como label, no entry.id
  • En sitios con view transitions, confirmar que el ‹nav› de migas NO lleva transition:persist
  • Comprobar que las páginas con noindex (login, checkout, gracias) NO emiten breadcrumbs

Preguntas frecuentes

¿Debo poner migas de pan en la home?

No. En la home el visitante ya está en la raíz: las migas serían una línea que dice solo Inicio y eso es ruido. La regla del componente lo refleja: las páginas que no declaran la prop breadcrumbs no pintan la barra.

¿Y si mi página tiene varios padres lógicos (un producto en dos categorías)?

Elige una sola jerarquía canónica por URL. Si el producto vive en /productos/audio/auriculares-x, las migas siguen esa ruta; el otro acceso (por marca, por uso) se resuelve con enlaces internos en el cuerpo, no con un segundo BreadcrumbList. Google solo admite una jerarquía por página.

¿Puedo omitir el JSON-LD si ya tengo microdata visible?

Puedes, pero pierdes el rich result. Los datos estructurados de Google priorizan JSON-LD para BreadcrumbList; la microdata visible te da una segunda señal y refuerza la semántica del HTML, pero por sí sola rara vez dispara el rastro bajo el título en los resultados.

¿Las migas reemplazan al menú principal?

No. El menú es navegación lateral (categorías hermanas, secciones del sitio); las migas son navegación jerárquica (ancestros de esta página). Un visitante en Servicios › Diseño de logotipo usa las migas para volver a Servicios, y el menú para saltar a Productos. Si las confundes, terminas con dos componentes que dicen lo mismo.

¿Tengo que actualizar las migas cuando renombro una categoría?

Si tu rastro nace de la URL o de una taxonomía central, no: el cambio se propaga solo. Si las hardcodeaste en cada página (anti-patrón D3), sí, y vas a olvidar alguna. La plantilla las declara por página, pero las etiquetas suelen venir de la misma fuente que el menú, así que un solo cambio en site.ts repinta todas.

¿Cómo se comparan las migas de Astro con las de Next.js o de Eleventy?

La diferencia es de orquestación, no de markup. Next.js usa el patrón de generateMetadata en cada page.tsx para emitir schema, y la composición visual la haces con un componente client/server híbrido en app/layout.tsx. Eleventy usa filtros Nunjucks para construir el rastro desde la colección. La ventaja de Astro 6 es que el componente visual y el buildSchema() viven en el mismo proceso de build, lo que hace trivial mantenerlos sincronizados — algo que en Next.js requiere disciplina extra para no duplicar la jerarquía entre el componente visual y el metadata. En Astro la regla B3 («un solo emisor de JSON-LD») se enforza naturalmente con un import central.

¿Cómo afecta el view transitions de Astro 6 al rastro visible?

Sin configuración especial, el ‹nav› se reemplaza junto con el resto del contenido en cada navegación, así que el visitante ve un breve cross-fade donde el rastro nuevo aparece al mismo tiempo que la página destino. Si quieres que el rastro se transicione de forma más suave (slide horizontal, por ejemplo), añade transition:name="breadcrumbs" al ‹nav› para que ambos elementos se traten como uno y la animación sea fluida. Lo que NO debes hacer es transition:persist: persistir el elemento entre páginas significa que el rastro de la página A se queda mientras el contenido de la B se carga, y el visitante ve durante 200 ms una jerarquía equivocada.

¿El componente sigue siendo accesible en lector de pantalla móvil (VoiceOver iOS, TalkBack Android)?

Sí, con dos matices. VoiceOver en iOS anuncia el ‹nav aria-label="Migas de pan"› como landmark si el usuario está navegando por landmarks (gesto de tres dedos + swipe right). El aria-current="page" se anuncia como «página actual» después del nombre del eslabón. TalkBack en Android tiene un comportamiento equivalente. El error que hemos visto es usar ‹nav role="navigation"› además del implícito: redundancia que algunos lectores anuncian como «navigation navigation». La regla: ‹nav› sin role explícito; el role lo da la etiqueta.

¿Cuál es la diferencia práctica entre aria-label y aria-labelledby para el ‹nav› de migas?

aria-label="Migas de pan" pone el texto directamente como label accesible. aria-labelledby="id-del-titulo" toma el label de un elemento existente del DOM (un ‹h2› oculto visualmente, por ejemplo). En migas usamos aria-label porque no necesitamos un título visible para los videntes — el patrón es lo suficientemente conocido. Si tu sitio es muy estricto con etiquetado redundante, puedes añadir un ‹h2 class="visually-hidden"›Navegación de migas‹/h2› y referenciarlo con aria-labelledby. En la práctica, aria-label es suficiente y más simple.

Las migas bien hechas son una de esas piezas que el visitante deja de ver porque siempre están ahí, en su sitio, sin estorbar. Un componente de cincuenta líneas que ahorra clicks, mejora el rich result y se valida sin advertencias en Search Console. El truco no está en el código —es trivial— sino en la disciplina de no emitir el JSON-LD dos veces y en mantener una sola fuente para ambos consumidores. Cuando un proyecto crece y entran tres desarrolladores al repositorio, esa disciplina se vuelve contrato del proyecto: el componente NO emite schema, el layout SÍ lo emite, y el code review bloquea cualquier PR que rompa la regla. La invariante se mantiene sola.

Sigue leyendo

¿Listo para dar el siguiente paso?

Cuéntanos qué necesitas y te respondemos hoy mismo.

¿Necesitas ayuda?