Guía de productos · El schema

El schema: el SEO que sale del catálogo

Del mismo .md que define un producto sale su JSON-LD: Product + Offer en la ficha, CollectionPage + ItemList en el grid. Todo centralizado en lib/seo.ts, con una regla dura —un único emisor por página— y cero ratings fabricados.

Es la última pieza del flujo: lo que el catálogo le dice a los buscadores. Los datos estructurados (JSON-LD) describen, en un lenguaje que Google entiende, que una página es un producto a la venta o una lista de productos. Y aquí no se escriben a mano: se generan a partir de los datos de la colección y de site.ts, así que nunca se desincronizan del contenido.

Detrás hay dos reglas duras que mantienen el grafo limpio. B3: un único emisor de schema por página —la card no emite, el grid emite la lista, la ficha emite el producto—, sin nodos duplicados ni @id en conflicto. Y B4: nunca se fabrica un rating ni una reseña; solo se modelan si son reales y verificables. Honestidad estructural que Google premia en lugar de penalizar.

Definición

¿Qué es el schema de un producto?

El JSON-LD (datos estructurados de schema.org) que describe el producto para los buscadores: un nodo Product + Offer en la ficha, y un CollectionPage + ItemList en el grid. Lo emite lib/seo.ts, no se escribe a mano.

El schema es la capa de datos estructurados —JSON-LD siguiendo el vocabulario de schema.org— que viaja invisible en cada página para que los buscadores entiendan QUÉ es. En una ficha de producto, ese schema es un nodo Product con su Offer (precio, disponibilidad, vendedor); en el catálogo, es un CollectionPage con un ItemList que enumera los productos.

Lo que lo hace fiable es que no se escribe en cada página: vive centralizado en lib/seo.ts, la librería SEO del sitio. Una función (buildSchema) recibe el tipo de página y los datos —que salen de la colección y de site.ts— y devuelve el array JSON-LD listo. Así el schema siempre coincide con el contenido: cambiar el precio en el .md cambia el Offer del schema, sin tocar nada más.

Función e importancia

¿Para qué sirve?

Le dice al buscador que la página es un producto (o una lista de productos), habilitando resultados enriquecidos. Y lo hace sin JSON-LD a mano, con un solo emisor por página (B3) y sin datos inventados (B4).

Su función es traducir el catálogo a un lenguaje que los buscadores entienden. Sin schema, Google ve texto e imágenes y adivina; con schema, sabe que la página es un Product con un precio y una disponibilidad, o un CollectionPage que lista productos. Eso habilita los resultados enriquecidos (precio, stock, migas de pan) y desambigua el tipo de contenido para la búsqueda y para las experiencias de IA.

Y lo hace de forma sostenible y honesta. Sostenible: el grafo se genera de los datos existentes, así que no hay JSON-LD que mantener en paralelo al contenido. Honesta: la regla B3 evita nodos duplicados que confundirían a Google, y la B4 prohíbe fabricar ratings —el Offer refleja el precio real o «bajo cotización»—. Datos estructurados que coinciden con la página: lo único que Google recompensa.

SEO técnico sin escribir JSON-LD

Del mismo .md que define el producto sale el Product+Offer y el ItemList, sin que escribas una línea de JSON-LD a mano. lib/seo.ts genera el grafo a partir de los datos de la colección y de site.ts. Cambiar el título o el precio cambia el schema solo.

Un solo emisor, cero conflictos

La regla B3 garantiza un único emisor de schema por página: la card no emite, el grid emite la lista, la ficha emite el producto. Sin @id duplicados, sin nodos que compitan, sin breadcrumbs por partida doble. El grafo se mantiene limpio y consolidado.

Honesto por diseño (B4)

El sistema nunca fabrica un aggregateRating ni reseñas para «verse mejor»: solo se emiten si son reales y verificables. Y el Offer refleja la realidad del precio (cifra o «bajo cotización»). Datos estructurados que coinciden con la página —lo que Google premia, no penaliza—.

Anatomía

¿Qué lo compone?

Cuatro piezas: el Product + Offer de la ficha, el CollectionPage + ItemList del grid, el selector buildSchema con su grafo base por @id, y las reglas B3 (un emisor) y B4 (sin rating fabricado).

Cada pieza cubre una capa. El Product describe la ficha; el ItemList describe el catálogo; buildSchema decide qué emitir y enlaza todo al grafo base (Organization, WebSite) por @id; y las reglas B3/B4 mantienen el grafo limpio y honesto. Juntas, convierten los datos de la colección en SEO técnico sin esfuerzo manual.

Abajo, el ejemplo en vivo —un nodo Product+Offer anotado—. Cada punto numerado se desglosa en su tarjeta: qué resuelve y qué función de lib/seo.ts lo emite.

1

Product + Offer (la ficha)

La ficha L4 emite un nodo Product con su Offer: nombre, descripción, imagen, sku, categoría, marca y el precio (real o «bajo cotización»). Es lo que permite a un buscador entender que la página describe un producto a la venta, no un artículo cualquiera.

Dato buildSchema("product", { product }) → lib/seo.ts

2

CollectionPage + ItemList (el grid)

El catálogo (L2) NO emite Product por cada card: emite UN CollectionPage con un ItemList que enumera los productos (nombre, URL, imagen). Así Google ve la lista entera como una colección ordenada, no como N productos sueltos compitiendo.

Dato directorySchema({ list }) → CollectionPage + ItemList

3

buildSchema + grafo base por @id

Un único selector (buildSchema) arma el array JSON-LD según el tipo de página, y siempre incluye el grafo base —Organization, WebSite, LocalBusiness— enlazado por @id. Todo nodo (Product, ItemList) referencia ese grafo, así Google consolida la entidad.

Dato buildSchema(pageType, data) · ORG_ID / WEBSITE_ID

4

Reglas B3 + B4

B3: un único emisor de schema por página —la card NO emite JSON-LD; el grid emite ItemList; la ficha emite Product—. B4: nunca se fabrica aggregateRating ni reseñas; solo se modelan si son reales y verificables. Honestidad estructural por diseño.

Dato B3 (un emisor) · B4 (sin rating fabricado)

Variantes

Qué emite cada página

Según el tipo de página y los datos, el schema cambia: Product con precio, Product «bajo cotización», el ItemList del grid, un FAQPage que convive con el Product, o la ausencia de rating fabricado. Todas las decide buildSchema.

No hay un único schema: hay un selector que emite el nodo correcto según la página. La ficha emite Product (con o sin precio); el grid, ItemList; una ficha con FAQs visibles añade FAQPage. Y por debajo de todo, la regla B4: jamás un rating inventado. El emisor es el mismo (buildSchema); cambia qué produce.

Abajo, cinco salidas —todas reales del sistema actual—. Cada una con la página donde aplica.

  • Product con precio (Offer con cifra)

    Retail · Rich result

    Con price, el Offer lleva la cifra, priceCurrency y priceValidUntil. Es el caso que puede pintar precio y disponibilidad en el buscador (Product es de los tipos con rich result vigente). El dato sale del frontmatter.

  • Product «bajo cotización»

    B2B · Sin precio público

    Sin price, el Offer emite un UnitPriceSpecification «bajo cotización» en vez de una cifra falsa. Honesto y conformante: declara que el precio es a consultar, no inventa un $0 real.

  • ItemList del catálogo

    Grid L2 · Colección

    El grid emite CollectionPage + ItemList: enumera los productos (posición, nombre, URL, imagen) como una colección. NO emite Product por card. Es lo que Google lee como «esta página lista productos».

  • Product + FAQPage (conviven)

    Ficha con dudas

    Si la ficha tiene faqs visibles, buildSchema añade un FAQPage JUNTO al Product —no en su lugar—. Dos nodos tipados que conviven sin pisarse. El FAQPage solo si las preguntas se ven en la página.

  • Sin rating fabricado (B4)

    Honestidad · Default

    No es un diseño: es la regla. emitReviews() devuelve vacío salvo reseñas reales con SITE.allowSelfReviews. El Product nunca lleva un aggregateRating inventado. Estrellas falsas = penalización.

Responsive y móvil

El schema, en el buscador móvil

El schema no se ve en la página —ni cambia con la pantalla: se emite una vez por página—, pero es justo lo que da forma al resultado en el buscador. En móvil, donde el espacio es mínimo, un rich result con precio o migas marca la diferencia en el clic.

A diferencia de las otras piezas, el schema es invisible y no tiene versión «de móvil»: se emite una vez por página, idéntico en cualquier dispositivo. Donde sí se manifiesta es en el resultado de búsqueda —y ahí el móvil es el escenario más exigente—: en una pantalla angosta, un resultado que muestra precio, disponibilidad o la ruta de migas gana el clic frente a una URL cruda.

Por eso el Product+Offer y el BreadcrumbList importan tanto en móvil: son los que habilitan esos resultados enriquecidos. Abajo, los dos casos —cómo el schema da forma al snippet en el buscador del teléfono— con la pieza del grafo que los produce.

1 · Rich result de Product (precio + disponibilidad)

Product es de los tipos con rich result vigente: con price y availability, el resultado móvil puede mostrar la cifra y «En stock». El dato sale del frontmatter, vía productSchema.

Schema → rich result de producto
<!-- LO QUE EL Product+Offer HABILITA EN EL BUSCADOR (móvil)
     Product es de los tipos con rich result vigente: con precio y
     disponibilidad, el resultado puede mostrar la cifra y "En stock".
     El dato sale del frontmatter → productSchema → el buscador. -->
Product { name, image, offers: { price: "450", availability: "InStock" } }
→ resultado enriquecido: foto · precio · disponibilidad

2 · Migas de pan en el resultado

buildSchema emite el BreadcrumbList una vez (B3). El buscador lo usa para mostrar la ruta (Inicio › Productos › Casco) en vez de una URL larga —clave en el ancho angosto del móvil—.

Schema → migas en el resultado
<!-- EL BreadcrumbList EN EL BUSCADOR (móvil)
     buildSchema emite el BreadcrumbList UNA vez (B3). El buscador lo
     usa para mostrar la ruta (Inicio › Productos › Casco) en vez de
     una URL cruda, en el ancho angosto del móvil. -->
BreadcrumbList: Inicio › Productos › Casco NOM-115
→ resultado con migas en vez de URL larga

Posición

¿Dónde vive?

Toda la lógica de schema vive en src/lib/seo.ts (productSchema, directorySchema, buildSchema). Las páginas solo DECLARAN su tipo y sus datos (pageType + schemaData); BaseLayout serializa el JSON-LD una vez.

El schema no vive en las páginas: vive centralizado en src/lib/seo.ts, la librería SEO del sitio. Ahí están productSchema (el Product+Offer), directorySchema (el ItemList), y buildSchema, el selector que arma el array según el tipo de página e incluye el grafo base por @id. Las páginas no construyen JSON-LD: solo declaran pageType y pasan schemaData.

El último eslabón es BaseLayout (a través de PageLayout), que llama a buildSchema y serializa el resultado en un único bloque <script type="application/ld+json">. Esa centralización es lo que hace cumplir B3 (un emisor por página) sin esfuerzo: como solo BaseLayout emite, no hay forma de duplicar el grafo desde un componente. Esta pieza cierra la guía: del .md al JSON-LD, todo se generó solo.

Implementación

Cómo se construye

productSchema arma el Product + Offer (honesto con/sin precio); directorySchema arma el CollectionPage + ItemList; y cada página declara su tipo (pageType) y sus datos (schemaData), que buildSchema convierte en JSON-LD.

productSchema recibe los datos del producto y devuelve el nodo Product con su Offer: si hay precio, lo incluye con priceValidUntil; si no, emite un UnitPriceSpecification «bajo cotización». directorySchema recibe la lista del catálogo y devuelve un CollectionPage con un ItemList numerado. Ambos enlazan al grafo base (Organization, LocalBusiness) por @id, así Google consolida la entidad.

La página no llama a esas funciones directamente: declara pageType="product" o "category" y pasa schemaData (el producto o la lista) a PageLayout. buildSchema hace el switch por tipo, añade el grafo base y el breadcrumb (una vez), y BaseLayout serializa todo. Abajo, las tres recetas: el Product, el ItemList y cómo una página los declara.

lib/seo.ts · productSchema (Product + Offer honesto)
// src/lib/seo.ts — productSchema: el Product + Offer de la ficha (extracto).
export function productSchema(p) {
  const url = absUrl(p.path)
  const offer = {
    '@type': 'Offer', url, priceCurrency: 'MXN',
    availability: 'https://schema.org/InStock',
    seller: { '@id': BUSINESS_ID },          // ← @id al grafo base
  }
  if (p.price) { offer.price = p.price }      // cifra real
  else { /* UnitPriceSpecification "bajo cotización" — sin precio falso */ }

  return {
    '@type': 'Product',
    '@id': `${url}#product`,
    name: p.name, description: p.description,
    image: p.images, sku: p.sku, category: p.category,
    brand: { '@type': 'Brand', name: p.brand ?? SITE.name },
    manufacturer: { '@id': ORG_ID }, url, offers: offer,
    // ...emitReviews(p.reviews)  ← SOLO si son reales (B4)
  }
}
lib/seo.ts · directorySchema (CollectionPage + ItemList)
// src/lib/seo.ts — directorySchema: el ItemList del grid (extracto).
export function directorySchema(data) {
  return {
    '@type': 'CollectionPage',
    name: data.name, description: data.description,
    url: absUrl(data.path),
    isPartOf: { '@id': WEBSITE_ID },          // ← @id al grafo base
    mainEntity: {
      '@type': 'ItemList',
      numberOfItems: data.items.length,
      itemListElement: data.items.map((it, i) => ({
        '@type': 'ListItem', position: i + 1,
        name: it.name, url: absUrl(it.path), image: absImage(it.image),
      })),
    },
  }
}
Astro · cómo cada página DECLARA su schema
---
// Cómo cada página DECLARA su schema (no lo escribe). buildSchema lo arma.
// LA FICHA (L4) → Product + Offer:
<ProductLayout ... />   // por dentro pasa pageType="product" + schemaData.product

// EL GRID (L2) → CollectionPage + ItemList:
const items = productos.map((p) => ({
  name: p.data.title, path: `/productos/${p.id}`,
  image: p.data.image, description: p.data.description,
}))
---
<PageLayout
  pageType="category"
  schemaData={{ list: { name: 'Productos', description: '…', path: '/productos', items } }}
>
  <!-- buildSchema('category', …) → emite CollectionPage + ItemList -->
</PageLayout>

En concreto: buildSchema(pageType, data) siempre incluye el grafo base (Organization + WebSite + LocalBusiness) en un @graph consolidado por @id, y según el pageType añade el nodo de página: productProduct; categoryCollectionPage + ItemList. El BreadcrumbList se añade una sola vez si la página pasa migas (B3).

La ficha (ProductLayout) arma su schemaData.product con { name, description, path, images, sku, category, price? } y, si no hay price, el Offer sale «bajo cotización». El grid pasa schemaData.list con los items. Ningún componente emite JSON-LD por su cuenta —emitReviews() devuelve vacío salvo reseñas reales (B4)—: el grafo es único, honesto y siempre alineado con lo que ve el visitante.

Buenas prácticas

Qué hacer y qué evitar

Un grafo sano cabe en unas reglas: dejar que lib/seo.ts emita el schema, un emisor por página (B3), la card sin schema, el Offer honesto y cero ratings fabricados (B4). El sistema hace cumplir casi todo.

Ninguna de estas reglas es opcional: son las que mantienen el grafo limpio y honesto. B3 (un emisor por página) y B4 (sin rating fabricado) son duras por una razón —Google penaliza los grafos duplicados y los datos inventados—. La centralización en lib/seo.ts las hace cumplir casi solas: como solo BaseLayout emite, no hay dónde duplicar.

La buena noticia es que tú no escribes JSON-LD: declaras el tipo de página y los datos, y el sistema arma el grafo correcto, honesto y consolidado. Lo único que debes evitar es la tentación de «mejorar» el schema a mano —un rating falso, un precio que no está en la página—. Abajo, lo que conviene y lo que conviene evitar.

Sí conviene

  • Deja que lib/seo.ts emita el schema: pasa los datos (product, list) vía PageLayout y buildSchema arma el JSON-LD. No escribas <script type="application/ld+json"> a mano.
  • Respeta B3 — un emisor por página: la ficha pasa pageType="product"; el grid, pageType="category". Cada página tiene UN nodo principal, no varios.
  • Deja que la card sea presentación pura: NO emite schema. El ItemList del grid ya cubre la lista; duplicar Product por card rompería B3.
  • Confía en el Offer honesto: con precio, lleva la cifra; sin precio, «bajo cotización». No fuerces un price en el schema si la página no lo muestra.
  • Modela reseñas SOLO si son reales y verificables (B4), con SITE.allowSelfReviews. En la duda, vacío: un rating inventado es peor que ninguno.

Mejor evita

  • NO escribas JSON-LD a mano en la página: se desincroniza del contenido y duplica lo que buildSchema ya emite. Pasa los datos, no el markup del grafo.
  • NO emitas Product por cada card del grid: rompe B3. El grid emite UN ItemList (la lista); el Product es de la ficha, una vez.
  • NO fabriques aggregateRating ni reseñas para «subir estrellas» (B4): Google penaliza las self-serving. Solo reseñas reales verificables.
  • NO pongas un precio en el schema que no esté en la página: el Offer debe coincidir con lo que ve el visitante. Sin precio público → «bajo cotización».
  • NO emitas el BreadcrumbList dos veces: lo emite buildSchema una vez (B3); el componente Breadcrumbs visual NO debe emitir su propio JSON-LD.
¿Necesitas ayuda?