wandres.dev
SEO · metadatos, OG, JSON-LD

Datos estructurados: JSON-LD con set:html

Hablarle al buscador en su propio vocabulario: qué son los datos estructurados y por qué JSON-LD es el formato preferido, cómo inyectar el bloque con la directiva set:html sin que Astro lo escape, y cómo modelar artículos, productos y migas de pan con schema.org derivando todo del frontmatter, sin mentirle nunca a la máquina.

⏱ 17 min

Un buscador lee tu HTML y adivina de qué trata, pero adivinar es frágil: no sabe con certeza si ese número es un precio o una talla, si esa fecha es de publicación o de un evento, si ese nombre es el autor o un personaje citado. Los datos estructurados eliminan la adivinación. Son anotaciones legibles por máquina que le dicen al buscador, en un vocabulario compartido, qué es cada cosa de tu página. A cambio, el buscador puede pintar resultados enriquecidos —estrellas de valoración, migas de pan, la fecha y el autor de un artículo— que destacan tu enlace entre los demás. En Astro los emites con un bloque JSON-LD y una directiva precisa, set:html, que evita que el JSON se corrompa al renderizarse.

🎯 Al terminar esta lección sabrás
  • Entender qué son los datos estructurados y por qué JSON-LD es el formato preferido.
  • Inyectar el bloque <script type="application/ld+json"> con set:html sin que Astro lo escape.
  • Modelar artículos, productos y migas de pan con el vocabulario de schema.org.
  • Derivar cada esquema del frontmatter y respetar la regla de que el marcado no puede mentir.

Qué son los datos estructurados y por qué JSON-LD

Los datos estructurados son metadatos que describen el significado de tu contenido usando un vocabulario estándar, schema.org, entendido por todos los buscadores. Existen tres sintaxis para incrustarlos —microdata y RDFa, que se entretejen con el HTML visible, y JSON-LD, que vive aparte en un bloque de script—, y hoy JSON-LD es la recomendada, por una razón de diseño: desacopla el dato semántico del marcado de presentación. No tienes que ensuciar tus etiquetas con atributos itemprop; describes la página en un objeto JSON independiente que puedes generar, versionar y razonar por separado.

Ese objeto se declara en dos campos raíz: @context, que casi siempre es la URL de schema.org, y @type, que nombra qué representas —un Article, un Product, una BreadcrumbList—. A partir de ahí, cada tipo define sus propiedades: un artículo tiene headline, author y datePublished; un producto tiene offers con price y availability. El premio no es un puesto más alto de forma directa —los datos estructurados no son un factor de ranking por sí mismos— sino la elegibilidad para resultados enriquecidos, que suben la tasa de clics porque tu enlace ocupa más espacio y comunica más.

Más allá de los tipos por página, dos esquemas describen el sitio entero y conviene emitirlos una vez desde el layout: Organization, que declara quién publica —nombre, logotipo, perfiles sociales—, y WebSite, que puede incluir un potentialAction de tipo SearchAction para que el buscador ofrezca una caja de búsqueda de tu sitio en los propios resultados. Son la identidad de fondo sobre la que se apoyan los esquemas de cada página, y por eso viven en el marco compartido y no en cada ruta.

set:html: inyectar el bloque sin que Astro lo escape

Aquí aparece un detalle técnico que, ignorado, rompe todo el mecanismo. Si intentas meter el JSON con una expresión normal dentro del script, Astro trata su contenido como texto y escapa los caracteres especiales: las comillas se convierten en entidades HTML y el bloque deja de ser JSON válido, así que el buscador lo descarta. La directiva set:html le dice a Astro que inserte esa cadena tal cual, sin escaparla, que es justo lo que el rastreador necesita para parsearla.

---
// src/components/JsonLd.astro
interface Props {
  data: Record<string, unknown>;
}
const { data } = Astro.props;
---
<script type="application/ld+json" set:html={JSON.stringify(data)} />

El componente es minúsculo pero hace una cosa bien: recibe un objeto, lo serializa con JSON.stringify y lo inyecta crudo. Que set:html sea explícito no es un capricho; es Astro obligándote a declarar que confías en ese contenido, porque insertar HTML sin escapar es justo la puerta por la que entran los ataques de inyección. Aquí es seguro porque el objeto lo construyes tú a partir de datos tipados, no de texto que teclee un usuario; esa distinción es la que debes tener siempre presente al usar la directiva.

⚠️
set:html sin datos de confianza es una vulnerabilidad

set:html inserta cadenas sin sanear, de modo que si alguno de los valores del objeto viniera de una entrada de usuario sin validar, estarías abriendo la puerta a inyección de scripts. En JSON-LD el riesgo es bajo porque los datos salen de tu frontmatter tipado, pero interioriza la regla general: set:html solo con contenido que tú controlas. Para el bloque JSON-LD, JSON.stringify sobre un objeto de datos verificados es seguro; concatenar en él texto libre de un formulario no lo es.

Esquemas útiles: Article, Product, BreadcrumbList

El vocabulario de schema.org es enorme, pero tres tipos cubren la mayoría de los casos. Para un artículo de blog, BlogPosting —una especialización de Article— describe el título, el autor, las fechas y la imagen, todo derivado del frontmatter que ya tienes.

---
const articuloLd = {
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  headline: post.data.title,
  description: post.data.description,
  datePublished: post.data.fecha.toISOString(),
  author: { '@type': 'Person', name: post.data.autor },
  image: new URL(`/og${Astro.url.pathname}.png`, Astro.site).href,
};
---
<JsonLd data={articuloLd} />

Para una ficha de producto, Product con un Offer anidado comunica precio, moneda y disponibilidad; si tienes valoraciones visibles en la página, un aggregateRating habilita las estrellas en el resultado.

---
const productoLd = {
  '@context': 'https://schema.org',
  '@type': 'Product',
  name: producto.nombre,
  image: new URL(producto.imagen, Astro.site).href,
  offers: {
    '@type': 'Offer',
    price: producto.precio,
    priceCurrency: 'EUR',
    availability: 'https://schema.org/InStock',
  },
};
---

Y la BreadcrumbList describe la ruta de navegación que lleva a la página, para que el buscador la muestre como migas de pan en vez de una URL cruda. Se modela como una lista ordenada de ListItem, cada uno con su posición y su enlace absoluto.

---
const migasLd = {
  '@context': 'https://schema.org',
  '@type': 'BreadcrumbList',
  itemListElement: migas.map((m, i) => ({
    '@type': 'ListItem',
    position: i + 1,
    name: m.nombre,
    item: new URL(m.ruta, Astro.site).href,
  })),
};
---

En páginas complejas —un artículo con sus migas y su editor— no necesitas tres scripts sueltos: el patrón @graph reúne varias entidades en un solo bloque, enlazadas entre sí por identificador, de modo que el buscador las lee como un grafo coherente y no como fragmentos aislados.

---
const grafo = {
  '@context': 'https://schema.org',
  '@graph': [articuloLd, migasLd, organizacionLd],
};
---
<JsonLd data={grafo} />

Reunirlas en un grafo no es sólo aseo: le permite al buscador entender que este artículo pertenece a esta organización y se alcanza por estas migas, relaciones que se pierden cuando cada esquema viaja por su cuenta sin referirse a los demás.

📰

Article / BlogPosting

Título, autor, fechas e imagen. Habilita el resultado enriquecido con byline y fecha.

🛒

Product

Con Offer para precio y disponibilidad, y aggregateRating para las estrellas si son visibles.

🧭

BreadcrumbList

La ruta de navegación como lista ordenada de ListItem, con enlaces absolutos.

flowchart LR
FM[frontmatter tipado] --> OBJ[objeto schema org]
OBJ --> JL[componente JsonLd]
JL --> SH[set html sin escapar]
SH --> SCRIPT[script ld json en el head]
SCRIPT --> BOT[rastreador del buscador]
BOT --> RICH[resultado enriquecido]
style OBJ fill:#89b4fa,color:#11111b
style RICH fill:#a6e3a1,color:#11111b
style FM fill:#f9e2af,color:#11111b
ℹ️
Valida con la herramienta de resultados enriquecidos

Antes de confiar en un esquema, pásalo por el validador de datos estructurados y la prueba de resultados enriquecidos del buscador. Te dicen si el JSON-LD parsea, si faltan propiedades obligatorias y si la página es elegible para el resultado enriquecido concreto. Es el equivalente al compilador para tu semántica: no garantiza que aparezcas, pero atrapa los errores que te dejarían fuera con certeza.

El marcado que miente es peor que el marcado que falta

Hay una regla en los datos estructurados que trasciende a la técnica y toca la ética del oficio: el marcado debe describir lo que el usuario ve, no lo que quisieras que creyera el buscador. Marcar una valoración de cinco estrellas que no aparece en la página, inventar un precio que no existe, declarar un autor que no firma: todo eso es spam estructurado, y los buscadores lo penalizan con sanciones manuales que hunden un sitio entero, no solo una página. La tentación es comprensible, porque el resultado enriquecido es visible y codiciado, y el marcado es invisible para el visitante; parece un lugar donde exagerar sin coste. Pero ahí está el error de fondo, y por eso esta lección cierra un arco. Los datos estructurados son la forma más pura de una idea que recorre todo el SEO técnico: le estás dando a una máquina la misma verdad que le das a un humano, en un formato que la máquina entiende mejor. El mapa —tu JSON-LD— y el territorio —tu página visible— tienen que coincidir, porque el buscador contrasta uno con otro y castiga la divergencia. Cuando entiendes esto, dejas de ver el marcado como un truco para engañar al algoritmo y empiezas a verlo como lo que es: un acto de traducción honesta. Traduces el significado de tu contenido del lenguaje del diseño, que los humanos leen, al lenguaje de schema.org, que las máquinas leen, y la única traducción que sobrevive es la fiel. La web semántica que se prometió durante décadas no llegó como una utopía de ontologías universales, sino como esta práctica humilde y pragmática: describir con exactitud, en un vocabulario compartido, lo que ya está ahí. Hacerlo bien no es optimizar; es decir la verdad en un segundo idioma.

⚔️ Anota tu contenido con schema.org
  1. Crea un componente <JsonLd data={...} /> que serialice un objeto con JSON.stringify y lo inyecte con set:html.
  2. Genera un esquema BlogPosting para un artículo, derivando headline, author y datePublished del frontmatter, y valídalo con la prueba de resultados enriquecidos.
  3. Añade una BreadcrumbList construida a partir de la ruta de la página, con cada item resuelto a URL absoluta contra Astro.site.
  4. Introduce a propósito un aggregateRating que no aparezca en la página visible, comprueba que el validador lo acepta pero razona por qué desplegarlo sería arriesgar una sanción, y quítalo.