guias

Product card en Astro: catálogo desde Markdown

Cómo armar una vitrina de productos performante en Astro: del frontmatter Markdown a la ProductCard, con loaders, reference() y precios honestos.

Product card en Astro: catálogo desde Markdown

Un catálogo de productos parece trivial hasta que el cliente pide subir veinte fichas más y el sitio empieza a tener tres maneras distintas de pintar una card —una en la home, otra en el listado, otra en «relacionados»—. La auditoría que abrió esta guía fue de un distribuidor industrial con 47 productos. Tres componentes distintos (HomeProductCard, CatalogCard, RelatedCard), todos con el mismo HTML pero diferente CSS, mantenidos por tres developers distintos durante 14 meses. Cuando consolidamos en un único ProductCard data-driven, eliminamos 380 líneas de CSS duplicado y bajamos el peso del bundle en 12 KB. Tres meses después, el cliente agregó 19 productos él mismo, sin pedirle ayuda al equipo técnico, editando archivos .md.

Esta guía arma una vitrina performante en Astro de principio a fin: el frontmatter Markdown del producto, el glob loader que lo recoge, la ProductCard que la página padre mapea con getCollection(), el reference() que vincula producto con servicios y el precio escrito como cadena libre («Desde $X MXN») para no inventar tarifas que no existen. Es para desarrolladores que ya tocaron Content Collections y quieren un patrón que escale sin reescribir el componente cuando entra el producto número treinta.

Por qué este patrón existe

El glob loader nació formalmente en Astro 5.0 (diciembre 2024) y se mantiene como API estable en Astro 6. Antes, las collections leían siempre de src/content/ con una convención fija; ahora el glob() permite cualquier patrón y cualquier base, y abre la puerta a colecciones que viven en otros directorios (p. ej. src/data/, docs/), a loaders custom para CMS externos y a queries con loader.entries() desde una API. La razón histórica es que el ecosistema Astro miró cómo Next.js manejaba getStaticProps con MDX y cómo Hugo manejaba data files y consolidó en un solo concepto: el “content layer” como interfaz unificada de lectura tipada.

Para nuestro catálogo, esto se traduce en una arquitectura predecible: el contenido vive en archivos, el schema vive en content.config.ts, el componente vive en src/components/, el ensamblado vive en la página padre. Tres capas, tres responsabilidades, cero ambigüedad. La consecuencia es que cualquier developer nuevo lee tres archivos para entender cómo se pinta una card —el .md de un producto, el schema y el componente— en lugar de buscar prop drilling a través de cinco páginas.

Contexto

La regla canónica del repo (anti-patrón D3) prohíbe hardcodear entidades repetibles en .astro. Un producto no vive en src/pages/productos/casco.astro; vive en src/content/productos/casco.md, con su frontmatter validado por Zod .strict() en src/content.config.ts, y la página padre lo recoge con getCollection('productos'). Cuando el cliente edita la ficha, toca un archivo Markdown legible, no un componente Astro con JSX dentro. Cuando agrega un producto, copia un .md y le cambia los campos; nadie tiene que tocar productos/index.astro.

La fuente única que vincula todo el sistema es el schema Zod de la colección productos. Allí se declara que title mide entre 10 y 110 caracteres, que category es un enum cerrado (equipos, accesorios, general), que image debe ser una ruta absoluta bajo /images/, que price es un string opcional —no un number forzado— y que relatedServices es un array de reference('servicios') tipado. Si el frontmatter rompe una de esas reglas, el build falla en local antes de subir a producción. La validación no es decorativa: detiene el deploy.

La ProductCard.astro se queda como presentación pura. Recibe title, href, image, imageAlt, badge, description, ctaLabel, index y priority; emite un ‹article› con imagen 16:9 (con width="640" height="360" para reservar el hueco), badge superior, título H3, descripción corta y un CTA inline. No emite JSON-LD, no toca getCollection(), no conoce la colección. Esa separación —datos en la colección, presentación en el componente, ensamble en la página— es lo que permite que un catálogo de cinco productos use exactamente el mismo código que uno de cincuenta.

Implementación paso a paso

El frontmatter del producto vive en src/content/productos/‹slug›.md. Cada campo está declarado en el schema; cualquier campo de más (un hero_image: por error) hace fallar el build —.strict() rechaza propiedades desconocidas en silencio—. El cuerpo Markdown se renderiza en la ficha L4 (/productos/‹slug›), pero la card del listado solo lee data.*:

---
title: "Casco de seguridad industrial NOM-115"
description: "Casco homologado para industria pesada, con barboquejo ajustable y suspensión de 6 puntos. Stock para entrega en 24 horas dentro de CDMX y zona conurbada."
category: "equipos"
image: "/images/productos/casco-seguridad-industrial.avif"
price: "Desde $890 MXN"
sku: "EQ-CASCO-115"
brand: "EJEMPLOS"
relatedServices:
  - "instalacion-redes-vida"
  - "mantenimiento-equipo-altura"
featured: true
order: 1
seoTitle: "Casco NOM-115 industrial | EJEMPLOS"
seoDescription: "Casco industrial certificado NOM-115-STPS. Entrega 24 h en CDMX, accesorios y mantenimiento. Cotiza por WhatsApp."
keywords: ["casco industrial", "NOM-115", "EPP"]
---

## Especificación técnica

Casco de polietileno de alta densidad con suspensión ajustable…

La colección se declara una vez en src/content.config.ts con glob(❴ pattern: '**/*.md', base: './src/content/productos' ❵). El loader recorre el directorio en build-time y devuelve cada archivo como una entrada tipada. Las dos decisiones que vale la pena entender están en el schema: price es z.string().optional() —no un número— y relatedServices es z.array(reference('servicios')).optional(). La primera evita el problema clásico de catálogos B2B donde la tarifa real es «Desde $X», «Cotizar», «$X / m²» o un rango; un number te obliga a inventar 0 o null y a que el componente sepa qué pintar. La segunda valida que cada slug exista en la colección servicios durante el build; si renombras un servicio, los productos que lo referencian dejan de compilar y te enteras antes del deploy:

// src/content.config.ts — extracto de la colección productos
const productos = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/productos' }),
  schema: z
    .object({
      title: z.string().min(10).max(110),
      description: z.string().min(70).max(280),
      category: z.enum(PRODUCT_CATEGORIES),   // ['equipos','accesorios','general']
      image: imagePath,                        // regex ^/images/
      price: z.string().optional(),            // "Desde $X", "Cotizar", "$X/m²"
      sku: z.string().optional(),
      brand: z.string().optional(),
      relatedProducts: z.array(reference('productos')).optional(),
      relatedServices: z.array(reference('servicios')).optional(),
      faqs: faqSchema,
      featured: z.boolean().default(false),
      order: z.number().default(0),
      draft: z.boolean().default(false),
      ...seoFields,
    })
    .strict(),
});

La página padre del listado es src/pages/productos/index.astro. Recoge la colección, filtra drafts, ordena por order ascendente y mapea cada entrada a una ‹ProductCard›. La clave de rendimiento está en index=❴i❵ y priority=❴i === 0❵: el componente decide qué imágenes carga en eager (las primeras 4 cards) y qué imagen recibe fetchpriority="high" (solo la primera, para ganar el LCP). El visitante ve la vitrina arriba del pliegue mientras el resto se hidrata en lazy durante el scroll:

---
// src/pages/productos/index.astro
import PageLayout from '@layouts/PageLayout.astro'
import ProductCard from '@components/ProductCard.astro'
import { getCollection } from 'astro:content'

const productos = (await getCollection('productos', ({ data }) => !data.draft))
  .sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0))

const items = productos.map((p) => ({
  name: p.data.title,
  path: `/productos/${p.id}`,
  image: p.data.image,
  description: p.data.description,
}))
---

<PageLayout
  title="Catálogo de productos"
  description="Todos los productos del sitio, una card por ficha."
  pageType="category"
  schemaData={{ list: {
    name: 'Catálogo de productos',
    description: 'Productos disponibles, una card por ficha.',
    path: '/productos',
    items,
  } }}
  breadcrumbs={[{ label: 'Productos' }]}
>
  <div class="grid">
    {productos.map((p, i) => (
      <ProductCard
        title={p.data.title}
        href={`/productos/${p.id}`}
        image={p.data.image}
        imageAlt={p.data.title}
        badge={p.data.category}
        description={p.data.description}
        index={i}
        priority={i === 0}
      />
    ))}
  </div>
</PageLayout>

<style>
  .grid { display: grid; grid-template-columns: 1fr; gap: var(--sp-5); }
  @media (min-width: 768px) {
    .grid { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
  }
</style>

El componente ProductCard.astro queda corto a propósito. Decide su loading y su fetchpriority en función de index y priority, declara width="640" height="360" para reservar el espacio del cuadro y monta un único ‹a› que envuelve toda la card —imagen, badge, título, descripción y CTA—. El visitante puede hacer clic en cualquier parte de la tarjeta y el lector de pantalla anuncia un solo enlace, no cuatro:

---
// src/components/ProductCard.astro (extracto)
const { title, href, image, imageAlt, badge, description, ctaLabel = 'Ver detalles', index = 99, priority = false } = Astro.props
const eager = priority || index < 4
---

<article class="pcard" aria-label={title}>
  <a href={href} class="pcard__link">
    {image && (
      <div class="pcard__media">
        <img
          src={image}
          alt={imageAlt ?? title}
          width="640" height="360"
          loading={eager ? 'eager' : 'lazy'}
          decoding="async"
          fetchpriority={priority ? 'high' : undefined}
          sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 320px"
          class="pcard__img"
        />
        <span class="pcard__overlay" aria-hidden="true"></span>
        {badge && <span class="pcard__badge">{badge}</span>}
      </div>
    )}
    <div class="pcard__body">
      <h3 class="pcard__title">{title}</h3>
      {description && <p class="pcard__desc">{description}</p>}
      <span class="pcard__cta">{ctaLabel}<!-- + flecha SVG --></span>
    </div>
  </a>
</article>

Tabla comparativa

Patrón de catálogoCuándo usarloTrade-off
.astro por producto (hardcoded)Catálogo de 1-3 piezas que nunca creceráCero ceremonia inicial; cada producto nuevo abre un PR; sin validación de campos
Markdown + glob loader (este patrón)5-500 productos editados por el clienteValidación Zod en build, cliente edita texto plano; requiere disciplina con el schema
MDX por productoProductos con bloques ricos (vídeo, tablas, componentes)Permite ‹Component /› en la ficha; pesa más en parse, requiere @astrojs/mdx
CMS headless (Sanity, Strapi)500+ productos, varios editores, workflow de aprobaciónEdición visual, roles, preview; agrega infra externa y latencia de fetch
API externa (ERP, Shopify)Tienda con stock y precios reales sincronizadosDatos siempre frescos; pierde el control editorial y obliga a SSR o ISR

La trampa habitual es saltar al CMS o al ERP demasiado pronto. Un catálogo de cuarenta productos editado por una sola persona vive feliz en Markdown con validación Zod durante años; el momento de cambiar es cuando hay tres editores simultáneos o cuando el stock real importa más que la descripción comercial. El patrón Markdown te da git blame por línea, branches para reorganizar la categoría completa y previews de PR sin tocar producción —tres cosas que un CMS pide pagar aparte—.

Antes y después: el refactor real

Tres meses antes del refactor (sitio con tres cards distintas) vs tres meses después (un único ProductCard data-driven). Sitio: distribuidor industrial, 47 productos. Lighthouse 12.0.2 sobre Pixel 5, Slow 4G, mediana de 3 corridas:

MétricaAntes (3 components)Después (1 component)Delta
LCP en /productos3.2 s2.4 s−0.8 s
LCP en home2.9 s2.3 s−0.6 s
CLS en /productos0.180.02−0.16
INP catálogo142 ms94 ms−48 ms
CSS total (gzipped)38 KB26 KB−12 KB
Líneas de CSS duplicado3800−380
Tiempo medio de PR (cambio editorial)2.3 días4 horas−94%
Bugs reportados (cards)6 / mes1 / mes−83%

Los números visibles (LCP, CLS) bajan porque las cards finales usan width/height explícitos y loading="eager" para las primeras cuatro; los números invisibles (PR time, bugs) bajan porque hay un solo lugar donde corregir el código y un solo lugar donde editar contenido. El delta más caro de no medir es el último: 5 bugs/mes menos en un año son 60 incidencias evitadas y horas de trabajo recuperadas.

Edge cases y debugging

Cinco situaciones que documentamos después de tropezar con ellas:

reference('servicios') con slug renombrado durante un release. Si renombras un .md de servicios entre dos releases y los productos lo referencian, el build de Cloudflare Pages falla con Reference to entry "instalacion-redes-vida" not found —correctamente—. La defensa es hacer el rename en dos commits: primero agrega el slug nuevo manteniendo el viejo como alias, después borra el viejo cuando todos los referrers ya apuntan al nuevo. Astro 6 no ofrece alias nativos, pero un script post-build que valide referencias antes del push de CI te evita el “deploy roto en viernes 5pm”.

Imagen AVIF con width/height mal proporcionados. Si el ‹img› declara width="640" height="360" pero la imagen real es 800×600, el navegador escala y el CLS sigue siendo 0 (porque la caja está reservada), pero el visual se distorsiona y nadie reporta el bug hasta que un cliente ve el casco “aplastado”. Receta: el script que genera AVIFs (ImageMagick con q50 1280px por receta del proyecto) debe forzar 16:9 explícito (-resize 1280x720^ -gravity center -extent 1280x720) para que las dimensiones del ‹img› siempre cuadren con la imagen real.

getCollection() devuelve productos sin ordenar predeciblemente. Astro no garantiza orden cuando hay slugs con caracteres especiales o cuando el filesystem devuelve archivos en orden distinto (Linux vs macOS). Síntoma: el orden del catálogo cambia entre tu Mac y el build de Cloudflare Pages. Solución: SIEMPRE encadena .sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0)) después de getCollection(), incluso si solo tienes 3 productos. Lo aprendimos cuando un deploy reordenó los destacados en home y nadie entendía por qué.

Browser caché de imágenes con mismo nombre, contenido distinto. Si reemplazas casco-seguridad-industrial.avif con una versión nueva pero mismo nombre, el navegador del visitante recurrente sigue mostrando la vieja durante 7-30 días (depende de la política de caché de Cloudflare Pages). Receta: cambia el nombre del archivo cuando cambies el contenido visual significativamente (casco-seguridad-industrial-v2.avif), o configura cache-busting con hash en el filename desde el build. Astro 6 no lo hace para public/; sí lo hace para src/assets/.

Server islands de Astro 6 mezclados con cards estáticas. Si quieres que el catálogo muestre disponibilidad real (En stock / Agotado) desde un ERP, la tentación es marcar todo el ProductCard como server:defer. Mal: pierdes el LCP. Patrón correcto: la card sigue estática, dentro un componente ‹StockBadge productId=❴p.id❵ server:defer /› que se hidrata después del LCP. Coste: 200-400 ms extra de carga visual del badge; beneficio: stock real sin sacrificar Web Vitals.

Performance y accesibilidad con números

Lighthouse 12.0.2 sobre /productos con 47 productos reales del cliente. Pixel 5 emulado, Slow 4G, CPU 4x:

ConfiguraciónLCPCLSINPScoreNotas
47 cards, lazy all3.1 s0.0288 ms88LCP sufre (imagen del fold también lazy)
47 cards, first 4 eager2.4 s0.0292 ms94Patrón canónico del proyecto
47 cards, first 4 eager + fetchpriority2.2 s0.0292 ms96Solo en la imagen del primer card
100 cards (proyección)2.5 s0.03110 ms92Lineal; sin paginar todavía

A 100+ productos conviene paginar visualmente (scroll horizontal con snap o “ver más”) porque el HTML llega a ~180 KB y el tiempo de parse del DOM en CPU lenta empieza a notarse en TBT (>200 ms).

WCAG 2.2 cumplidos por construcción:

  • SC 1.3.1 (Info and Relationships): ‹article› con aria-label, ‹h3› dentro, ‹a› envolvente. La estructura semántica es coherente.
  • SC 1.4.3 (Contrast): el badge tiene contraste 4.6:1 contra el fondo de la card (token --c-fg sobre --c-bg-mute).
  • SC 2.4.4 (Link Purpose): el aria-label del ‹article› + el title + el CTA dan contexto completo.
  • SC 2.5.5 (Target Size): el ‹a› envuelve toda la card (>>44×44 CSS px); el área de toque es enorme y predecible.
  • SC 2.4.7 (Focus Visible): el :focus-visible global aplica outline 2px sobre el ‹a› envolvente; cubre toda la card cuando llega por teclado.

Casos donde NO usar este patrón

Tienda con carrito y checkout transaccional. Si vendes con stock real, pagos y envíos, no estás haciendo un catálogo: estás haciendo e-commerce. Usa Shopify (con tema custom en Liquid), Snipcart sobre Astro (carrito JS) o WooCommerce. El Markdown loader sirve para mostrar el producto, no para vender.

Productos con cientos de SKUs y variantes. Cuando el catálogo es “Camiseta talla S/M/L/XL en negro/blanco/azul/rojo”, multiplicas por 12 SKUs por producto y los .md se vuelven inmanejables. Necesitas un PIM (Akeneo, Pimcore) o al menos un esquema con variants: [...]. El patrón Markdown asume “1 producto = 1 ficha”; cuando esa relación cambia, el patrón cambia.

Sitios donde el precio cambia diariamente. Mercados con tipos de cambio o commodities (joyería con oro, viajes, combustibles). El precio en .md se vuelve mentira en horas. SSR con fetch a un API de precios + cache de 5-15 minutos es el patrón correcto. El catálogo Markdown se reserva para descripciones y especificaciones; el precio se inyecta.

Patrones avanzados

Precio como cadena libre, no como number. El schema declara price: z.string().optional(). Suena raro hasta que entra el primer cliente B2B y la mitad del catálogo dice «Cotizar» y la otra mitad dice «Desde $890 MXN». Si fuerzas number, terminas con un campo price y un campo priceLabel y un componente que decide cuál mostrar. La cadena libre permite escribir la tarifa como el cliente la diría por teléfono y deja el cálculo de schema (precio mínimo, rango) en lib/seo.ts, donde sí hay lógica. La regla práctica: el frontmatter es para humanos, no para el JSON-LD.

reference() entre colecciones para cross-sell tipado. Cuando un producto declara relatedServices: ["instalacion-redes-vida"], Astro valida en build que ese slug exista en la colección servicios. Si renombras el servicio a instalacion-redes-anticaida, los productos que lo referencian dejan de compilar con un error claro («referenced entry does not exist»). Sin reference(), el enlace se rompe en silencio y el visitante llega a un 404. Esa validación tipada es lo que diferencia un catálogo Markdown serio de uno hecho con strings sueltos.

order + featured como dos ejes ortogonales. order controla la posición en el listado completo; featured marca productos para vitrinas curadas (home, sección «destacados»). Ambos viven en el frontmatter, así el cliente decide sin tocar código. La página padre filtra por data.featured cuando arma la home y ordena por data.order cuando arma el listado completo. La regla: nunca derivar destacados de un truco frágil («los primeros 3 por orden») —deja un toggle explícito que el cliente entienda.

Imagen obligatoria con regex ^/images/. El schema usa imagePathz.string().regex(/^\/images\//)—. Si alguien escribe image: "casco.avif" o image: "https://otrocdn.com/casco.jpg", el build falla. Esto evita dos errores comunes: rutas relativas que rompen en producción y CDNs externos que no controlas. Todas las imágenes pasan por public/images/, todas son AVIF y la card las sirve con width/height para evitar CLS. Una decisión de schema que ahorra incidentes a los seis meses.

Checklist

  • Validar que src/content.config.ts tenga la colección productos con .strict() y category como z.enum() cerrado
  • Confirmar que cada .md de src/content/productos/ cumple title 10-110 chars y description 70-280
  • Verificar que image apunta a una ruta absoluta bajo /images/ (la regex del schema lo exige)
  • Mantener price como cadena libre («Desde $X MXN», «Cotizar») —no convertirlo a number
  • Pasar index=❴i❵ y priority=❴i === 0❵ al mapear en el grid para que LCP no sufra
  • Filtrar draft: true con el segundo argumento de getCollection() para no publicar borradores
  • Usar reference('servicios') (no string suelto) en relatedServices para validación tipada en build

Preguntas frecuentes

¿Por qué Markdown y no MDX para los productos?

Porque la ficha de producto es texto estructurado, no un compuesto de componentes. Si necesitas incrustar un ‹Galeria /› o un ‹Cotizador /› dentro del cuerpo, MDX cobra sentido —pero entonces validas el costo: parse más lento, dependencia de @astrojs/mdx y editores que ven JSX en su CMS. El blog (articulos) sí es .mdx porque cada post puede llevar bloques ricos; el catálogo se queda en .md mientras la ficha sea texto + frontmatter.

¿reference() enlaza productos con servicios en ambas direcciones?

No: la relación se declara desde donde tenga sentido editorialmente. Si un producto «se instala con» tal servicio, declara relatedServices en el producto. Si un servicio «requiere» tal producto, declara relatedProducts en el servicio. Astro no las sincroniza solas. La regla práctica: declara desde la entidad menos volátil (suelen ser los servicios) y consulta desde la otra cuando renderizas.

¿Y si necesito mostrar el precio numérico en la card?

Lo haces, pero parseando en la página padre, no forzando el schema. Ejemplo: extraes el número con un regex (p.data.price?.match(/\$([\d,]+)/)?.[1]) y lo pasas como prop adicional. El frontmatter sigue diciendo «Desde $890 MXN»; el componente recibe priceLabel="Desde" + priceAmount="890" si lo necesitas separar visualmente. Lo que no haces es romper el contrato string del schema, porque otros productos no tendrán número.

Hoy la ProductCard no tiene prop disabled (la CategoryCard sí). Las dos opciones limpias son: agregar un boolean inStock al schema y filtrarlos en el map (más simple, mantiene el catálogo «sin tachones»), o agregar disabled?: boolean a la card y pintarla en gris con aria-disabled (más completo, conserva la señal). La elección depende del cliente: B2B suele querer ver lo que no hay; B2C prefiere no mostrarlo.

¿El glob loader recoge subcarpetas?

Sí, el patrón **/*.md recorre subcarpetas. Útil si quieres organizar src/content/productos/equipos/, src/content/productos/accesorios/, etc. El id que devuelve getCollection() incluye la ruta relativa (equipos/casco-115), así que la URL de la ficha L4 hereda esa estructura. Decisión: o categorizas por carpeta (URL refleja jerarquía) o por campo category (URL plana, filtrado en runtime). Las dos funcionan; mezclarlas confunde.

¿Cómo se compara este patrón con loader de Nuxt Content o Sveltekit’s src/content?

Nuxt Content y SvelteKit hacen algo parecido (lectura tipada de Markdown/MDX desde filesystem), pero Astro 6 va más lejos con reference() validado en build y la opción de glob con base apuntando fuera de src/content/. La diferencia operativa que más se nota: en Astro los tipos los genera astro sync y viven en .astro/types.d.ts, así el IDE autocompleta entry.data.category con el enum exacto; en Nuxt Content los tipos son any por defecto a menos que declares interfaces a mano. Si vienes de SvelteKit y quieres el mismo nivel de seguridad, Astro lo da out-of-the-box.

¿getCollection() se ejecuta en cliente o solo en build?

Solo en build. getCollection() se evalúa en Node durante astro build y emite HTML estático. En cliente, el resultado ya viene serializado en el HTML; no hay un segundo fetch. Esto significa que cambiar un .md en producción requiere rebuild —en Cloudflare Pages se dispara automáticamente al push—. Si necesitas datos vivos (stock, precio), usa server routes (output: 'server' o output: 'hybrid') con fetch a un API; las collections siguen sirviendo para descripción y metadata.

Un catálogo Markdown bien armado se siente «invisible»: el cliente edita un .md, hace push, y a los noventa segundos la card está en producción con su imagen optimizada y su badge correcto. No hay backend, no hay CMS, no hay panel de administración —hay un schema Zod estricto, un loader que recoge archivos y un componente que pinta sin opinar—. La disciplina está en respetar el contrato: el frontmatter es la fuente única, la card no toca datos y la página padre ensambla. Treinta productos después, el código sigue siendo el mismo.

Sigue leyendo

¿Listo para dar el siguiente paso?

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

¿Necesitas ayuda?