Saltar al contenido

Datos estructurados en Next.js, generados desde un solo grafo

Casi todo el JSON-LD se publica como cuatro bloques inconexos que redeclaran la organización. Un grafo conectado con referencias @id estables es menos código, y es el que un rastreador puede resolver.

6 min de lectura

Los datos estructurados son la parte del SEO técnico con mayor distancia entre lo que los equipos publican y lo que la especificación premia. La implementación habitual es un componente por tipo de página, cada uno emitiendo su propio <script type="application/ld+json">, y cada uno redeclarando la organización, el sitio y el logotipo.

Valida. También le dice a un rastreador que tu sitio contiene cuatro organizaciones sin relación que casualmente comparten nombre.

Un grafo, no cuatro bloques

La clave @graph existe para esto. Contiene una lista de nodos, cada uno con un @id estable, y los nodos se refieren entre sí por ese @id en lugar de repetir su contenido.

{
  "@context": "https://schema.org",
  "@graph": [
    { "@type": "Organization", "@id": "https://example.com/#org", "name": "Example" },
    { "@type": "WebSite", "@id": "https://example.com/#site", "publisher": { "@id": "https://example.com/#org" } },
    { "@type": "WebPage", "@id": "https://example.com/blog/post#page", "isPartOf": { "@id": "https://example.com/#site" } },
    { "@type": "Article", "@id": "https://example.com/blog/post#article", "mainEntityOfPage": { "@id": "https://example.com/blog/post#page" } }
  ]
}

Cuatro nodos, una organización, y cada relación dicha una sola vez. Los valores de @id son el mecanismo entero: son lo que permite a un rastreador fusionar el nodo que ve aquí con el mismo nodo en cualquier otra página.

Dos reglas los hacen funcionar:

  • URLs absolutas, siempre. #org por sí solo no es un identificador, es un fragmento que significa algo distinto en cada página.
  • Estables para siempre. Un @id que cambia entre despliegues -porque se construyó desde un slug que alguien editó, o desde el id de una fila- tira a la basura todo lo que el rastreador había acumulado sobre él.

Generarlo en vez de escribirlo

El JSON-LD escrito a mano se desincroniza porque nada lo obliga a coincidir con la página. El título cambia en el export metadata y el headline de los datos estructurados conserva el viejo, porque son dos cadenas en dos ficheros.

La solución es un helper tipado, llamado desde donde ya se conoce la respuesta:

// lib/jsonld.ts
export function articleNode({ url, title, description, published, modified }: ArticleInput) {
  return {
    '@type': 'Article',
    '@id': `${url}#article`,
    headline: title,
    description,
    datePublished: published,
    dateModified: modified ?? published,
    mainEntityOfPage: { '@id': `${url}#page` },
    publisher: { '@id': `${SITE}#org` },
  };
}
// app/blog/[slug]/page.tsx
<JsonLd data={graph(
  webPageNode({ url, title: doc.title, description: doc.description }),
  articleNode({ url, title: doc.title, description: doc.description,
                published: doc.date, modified: doc.updated }),
  breadcrumbNode(trail),
)} />

Ahora los datos estructurados no pueden contradecir a la página, porque leen las mismas variables que la página renderiza. Es el mismo principio que canónicos y sitemaps que no pueden desincronizarse: una sola fuente de verdad, generada en los dos extremos.

Dónde va dentro de la respuesta

En el HTML renderizado en servidor. Un <script> inyectado por un componente de cliente después de la hidratación es marcado que un rastreador puede ejecutar o no, y no hay razón para apostar: el dato se conoce en el servidor.

export function JsonLd({ data }: { data: object }) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
    />
  );
}

dangerouslySetInnerHTML es lo correcto aquí y no un atajo: React escapa los hijos de texto, lo que corrompe el JSON. Lo que no debes hacer es interpolar texto controlado por el usuario sin escapar <: un comentario que contenga </script> cerraría la etiqueta y ejecutaría lo que venga después.

Los tipos que valen la pena y los que son teatro

Los datos estructurados se ganan su sitio cuando mapean a un resultado enriquecido o resuelven una entidad. Más allá de eso son marcado que nadie lee.

Valen la pena:

  • Organization y WebSite, una vez, referenciados desde todas partes.
  • BreadcrumbList, que aparece directamente en los resultados.
  • Article en artículos, y Product con datos reales de Offer en productos.
  • FAQPage, pero solo donde las preguntas estén visibles en la página. Marcar preguntas que no se renderizan es una violación de las directrices, no un truco.
  • LocalBusiness o un subtipo donde haya una dirección real. Ojo: los subtipos exigen address - un ProfessionalService sin ella es inválido, y es una forma frecuente de publicar un grafo roto creyendo que es más rico.

No valen gran cosa:

  • Review escrita por ti sobre ti. El marcado de reseñas propias lleva años sin ser elegible para resultados enriquecidos.
  • HowTo y Recipe en páginas que no son ninguna de las dos.
  • speakable, SiteNavigationElement y el resto de la cola larga que ninguna superficie consume.

Comprobarlo

Dos herramientas, y responden preguntas distintas. La Prueba de resultados enriquecidos te dice si una página es elegible para un tipo de resultado concreto. El Schema Markup Validator te dice si tu grafo está bien formado: es el que detecta una referencia @id colgando, cosa que la primera ignora tan tranquila.

Ejecuta las dos sobre el HTML renderizado de la página desplegada, no sobre un fragmento pegado. El fragmento es lo que escribiste; el HTML renderizado es lo que publicaste, y en sitios con una capa de caché delante no son fiablemente lo mismo.

Volver a todos los artículos