Los algoritmos de búsqueda contemporáneos no se limitan a analizar palabras clave aisladas: mapean entidades, relaciones semánticas y tipos de contenido. Para estandarizar este modelo, un consorcio de los principales motores de búsqueda creó el vocabulario Schema.org.
Históricamente, los microdatos se embebían directamente en el marcado visual:
itemscope, itemtype e itemprop enlazados a elementos del DOM;vocab, typeof y property.En arquitecturas modulares basadas en componentes como React, el enfoque de Microdata genera un fuerte acoplamiento (tight coupling): cualquier cambio estético o reestructuración de contenedores en una tarjeta de producto puede romper inadvertidamente el grafo semántico de Schema.org.
Por este motivo, el estándar de facto adoptado por Google, Yandex, Bing y motores generativos de inteligencia artificial (ChatGPT Search y Perplexity) es el formato JSON-LD.
JSON-LD desacopla completamente los datos estructurados respecto a la interfaz de usuario. Sin embargo, en aplicaciones Client-Side Rendered (CSR) surgen tres desafíos esenciales de ingeniería:
JSON.stringify(data) en JSX introduce riesgos de Cross-Site Scripting si existen cadenas no sanitizadas que contengan el cierre </script>.any: Modelar entidades válidas de Schema.org requiere validación exhaustiva mediante librerías de tipos como schema-dts.+-------------------------------------------------------------------------+
| ARQUITECTURA DEL GRAFO SEMÁNTICO JSON-LD EN SPA |
+-------------------------------------------------------------------------+
[ Componente de Página en React SPA ]
|
| 1. Datos tipados de la entidad procedentes de la API (DTO)
v
+-----------------------------------------------------------------------+
| Generador de Esquema Schema.org (schema-dts) |
| - Validación estructural: @type: "TechArticle" | "Product" | "FAQPage"|
| - Grafos vinculados: Autor (Person), Editorial (Organization) |
+-----------------------------------------------------------------------+
|
| 2. Sanitización contra XSS (Escape de </script>, <, >, &)
v
+-----------------------------------------------------------------------+
| <script type="application/ld+json" id="jsonld-primary"> |
| { |
| "@context": "https://schema.org", |
| "@type": "Product", |
| "name": "Mechanical Keyboard", |
| "offers": { "@type": "Offer", "price": "149.00" } |
| } |
| </script> |
+-----------------------------------------------------------------------+
|
| 3. Ingesta por WRS / Googlebot / Extractores de LLMs
v
+-----------------------------------------------------------------------+
| Resultados de Búsqueda: Snippet Enriquecido / Resumen de IA |
| ★★★★★ 4.9 (128 valoraciones) | En stock | 149.00 USD |
+-----------------------------------------------------------------------+Al diseñar datos estructurados en aplicaciones React SPA, se aplican tres directrices fundamentales:
@id):
JSON-LD permite enlazar entidades mediante el identificador URI @id. Esto posibilita que esquemas independientes (como un Producto y su lista de navegación BreadcrumbList) hagan referencia compartida a una misma Organización o Autor sin duplicar información.<title> o <meta>, React no fusiona automáticamente el contenido de etiquetas <script> arbitrarias. Para gestionar JSON-LD en una SPA se deben asignar identificadores id deterministas o emplear componentes que sustituyan limpiamente el contenido textual del nodo al cambiar de ruta.</script><script>alert(1)</script>, el analizador HTML del navegador finalizará anticipadamente el bloque de datos y ejecutará el script malicioso. Para neutralizar este vector, todos los caracteres HTML peligrosos deben codificarse con secuencias de escape Unicode (\u003c, \u003e, \u0026).Product, Offer y AggregateRating generan resultados enriquecidos en Google, mostrando precios actualizados, disponibilidad en almacén y estrellas de valoración.TechArticle, SoftwareSourceCode y BreadcrumbList habilitan migas de pan semánticas en las SERPs y facilitan que motores de IA como Perplexity y ChatGPT Search atribuyan las fuentes con exactitud.FAQPage despliega acordeones interactivos directamente bajo el resultado orgánico en los buscadores.A continuación se detalla una solución tipada y segura contra inyecciones XSS utilizando schema-dts.
/**
* Serializa de forma segura un objeto a cadena JSON para incrustarlo en <script>.
* Neutraliza los caracteres <, >, & y separadores de línea Unicode para mitigar ataques XSS.
*/
export function serializeSafeJsonLd(data: unknown): string {
const jsonString = JSON.stringify(data, null, process.env.NODE_ENV === 'development' ? 2 : 0);
if (!jsonString) {
return '{}';
}
// Escapar caracteres conflictivos conforme a las directrices W3C/HTML5
return jsonString
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028') // Line Separator
.replace(/\u2029/g, '\\u2029'); // Paragraph Separator
}El paquete schema-dts proporciona las definiciones completas de TypeScript para todo el catálogo de Schema.org:
import React from 'react';
import type { Thing, WithContext } from 'schema-dts';
import { serializeSafeJsonLd } from '@/utils/safe-json-ld';
export interface JsonLdProps<T extends Thing> {
schema: WithContext<T>;
id?: string;
}
/**
* Componente universal para inyección de datos estructurados JSON-LD.
* Garantiza validación estricta de Schema.org y blindaje total ante XSS.
*/
export function JsonLd<T extends Thing>({ schema, id }: JsonLdProps<T>) {
const scriptId = id || `jsonld-${schema['@type'] ? String(schema['@type']).toLowerCase() : 'schema'}`;
return (
<script
id={scriptId}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeSafeJsonLd(schema) }}
/>
);
}import React from 'react';
import type { Product, BreadcrumbList, WithContext } from 'schema-dts';
import { JsonLd } from '@/components/seo/JsonLd';
interface ProductItem {
id: string;
name: string;
sku: string;
description: string;
brand: string;
price: number;
currency: string;
ratingValue: number;
reviewCount: number;
imageUrl: string;
}
interface ProductDetailsProps {
product: ProductItem;
}
export const ProductDetailsPage: React.FC<ProductDetailsProps> = ({ product }) => {
// 1. Construcción del esquema Product con ofertas y valoraciones
const productSchema: WithContext<Product> = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
image: product.imageUrl,
description: product.description,
sku: product.sku,
brand: {
'@type': 'Brand',
name: product.brand,
},
offers: {
'@type': 'Offer',
url: `https://agenticlab.dev/products/${product.sku}`,
priceCurrency: product.currency,
price: product.price.toFixed(2),
availability: 'https://schema.org/InStock',
itemCondition: 'https://schema.org/NewCondition',
},
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: product.ratingValue.toString(),
reviewCount: product.reviewCount.toString(),
bestRating: '5',
worstRating: '1',
},
};
// 2. Construcción del esquema BreadcrumbList
const breadcrumbSchema: WithContext<BreadcrumbList> = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Inicio',
item: 'https://agenticlab.dev',
},
{
'@type': 'ListItem',
position: 2,
name: 'Catálogo',
item: 'https://agenticlab.dev/products',
},
{
'@type': 'ListItem',
position: 3,
name: product.name,
item: `https://agenticlab.dev/products/${product.sku}`,
},
],
};
return (
<main className="container mx-auto p-6">
{/* Inyección de Datos Estructurados */}
<JsonLd schema={productSchema} id="product-jsonld" />
<JsonLd schema={breadcrumbSchema} id="breadcrumbs-jsonld" />
<h1 className="text-2xl font-bold">{product.name}</h1>
<p className="text-gray-600">{product.description}</p>
<div className="price-tag text-xl font-semibold mt-4">
{product.price} {product.currency}
</div>
</main>
);
};Prueba automatizada para verificar la integridad de las etiquetas de microdatos en pipelines continuos:
import { chromium } from 'playwright';
export async function validateJsonLdOnPage(url: string): Promise<void> {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle' });
// Extraer todos los bloques JSON-LD presentes en el DOM renderizado
const jsonLdScripts = await page.locator('script[type="application/ld+json"]').allInnerTexts();
if (jsonLdScripts.length === 0) {
throw new Error(`[Schema Error]: No se encontraron bloques JSON-LD en ${url}`);
}
for (const scriptContent of jsonLdScripts) {
try {
const parsed = JSON.parse(scriptContent) as Record<string, unknown>;
if (!parsed['@context'] || !parsed['@type']) {
throw new Error(`[Schema Error]: Faltan las claves requeridas @context o @type`);
}
console.info(`[Schema Valid]: Parseo exitoso para @type: ${String(parsed['@type'])}`);
} catch (err: unknown) {
throw new Error(`[Schema Error]: JSON malformado: ${err instanceof Error ? err.message : 'Unknown'}`);
}
}
await browser.close();
}Emplear <script dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} /> sin escapar caracteres. Si un usuario malintencionado introduce </script><script>stealTokens()</script> en una opinión o descripción, el navegador cerrará prematuramente el bloque de datos y ejecutará el script atacante.
Solución: Filtrar siempre la serialización a través de serializeSafeJsonLd, convirtiendo <, > y & en secuencias Unicode (\u003c, \u003e, \u0026).
Google y los principales motores penalizan severamente cualquier discrepancia entre los datos declarados y lo visible para el usuario. Mostrar un precio de $99 y 5 estrellas en el JSON-LD mientras la web muestra $199 sin reseñas motiva una sanción manual (Manual Action), inhabilitando los rich snippets para todo el dominio.
Solución: Construir el JSON-LD tomando exclusivamente las mismas propiedades y DTOs utilizados para renderizar el contenido en pantalla.
Montar etiquetas <script type="application/ld+json"> dinámicamente sin un id estático puede causar que se acumulen múltiples bloques en el DOM tras sucesivas navegaciones. Googlebot, en la segunda ola de rastreo de WRS, detectará decenas de productos antagónicos en una misma página.
Solución: Especificar un atributo id determinista (por ejemplo, id="product-jsonld") para que React actualice el nodo existente en lugar de concatenar nuevos elementos.
Utilizar cadenas con formatos regionales ("1 299,50 €") o fechas literales ("12 de mayo de 2026"). Schema.org exige taxativamente el punto decimal para cantidades monetarias ("1299.50") y el estándar ISO 8601 UTC para fechas ("2026-05-12T10:00:00Z").
Solución: Asegurar el formateo con .toFixed(2) en precios numéricos y .toISOString() en fechas.
Definir las entidades usando interfaces de schema-dts (TechArticle, Product, FAQPage) evitando cualquier uso de any.
Serializar la entidad reemplazando los caracteres <, >, & por secuencias seguras Unicode \u003c, \u003e, \u0026.
Incrustar el bloque con un id fijo en el árbol de componentes para garantizar actualizaciones fluidas en cada ruta.
Validar la conformidad semántica mediante APIs de Rich Results y pruebas con Playwright en los Quality Gates.