Поисковые алгоритмы современного веба оценивают не только ключевые слова, но и семантическую структуру данных страницы: сущности (Entities), связи между ними и их типы. Для этого консорциумом ведущих поисковых систем был разработан словарь Schema.org.
Исторически микроразметка реализовывалась через встроенные HTML-атрибуты:
itemscope, itemtype, itemprop непосредственно на DOM-элементах;vocab, typeof, property.В компонентной архитектуре React подход Microdata порождает сильную связанность (tight coupling): изменение UI-разметки (верстки карточки товара, кнопок, оберток) способно случайно разорвать семантический граф Schema.org.
По этой причине стандартом де-факто поисковых систем (включая Google, Yandex, Bing и генеративные AI-поисковики ChatGPT Search и Perplexity) стал формат JSON-LD.
JSON-LD полностью отделяет логику данных от визуального представления интерфейса. Однако в контексте Client-Side Rendered (CSR) SPA возникают три специфические инженерные задачи:
JSON.stringify(data) внутри JSX создает уязвимость инъекции скриптов при наличии в данных комбинаций </script>.any: Использование словарей Schema.org требует исчерпывающей валидации типов через библиотеку schema-dts.+-------------------------------------------------------------------------+
| АРХИТЕКТУРА СЕМАНТИЧЕСКОГО ГРАФА JSON-LD |
+-------------------------------------------------------------------------+
[ React SPA Компонент ]
|
| 1. Данные сущности из API (TypeScript Interface)
v
+-----------------------------------------------------------------------+
| Построитель схемы Schema.org (schema-dts) |
| - Валидация структуры: @type: "TechArticle" | "Product" | "FAQPage" |
| - Вложенные графы: Author (Person), Publisher (Organization) |
+-----------------------------------------------------------------------+
|
| 2. Безопасная санитизация (Экранирование </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. Индексация WRS / Googlebot / LLM Parser
v
+-----------------------------------------------------------------------+
| Поисковая выдача: Расширенный сниппет (Rich Snippet / AI Overview) |
| ★★★★★ 4.9 (128 отзывов) | В наличии | 149.00 USD |
+-----------------------------------------------------------------------+При проектировании структурированных данных в React SPA учитываются следующие принципы:
@id. Это позволяет сослаться на сущность организации или автора из нескольких независимых схем без дублирования информации.<title> и <meta>, React не всегда выполняет автоматический мерж содержимого произвольных тегов <script>. Для управления JSON-LD в SPA используется либо нативный хойстинг с уникальным id, либо специализированный хук, контролирующий замену текстового содержимого единого скрипт-узла при смене роута.</script><script>alert(1)</script>, парсер HTML браузера завершит чтение текущего скрипта на закрывающем теге и выполнит вредоносный код. Поэтому все опасные HTML-символы внутри JSON экранируются с использованием Unicode-эскейп-последовательностей.Определение структуры схемы с помощью словарей schema-dts (TechArticle, Product, FAQPage) без использования типа any.
Преобразование объекта в безопасный JSON с заменой <, >, & на Unicode-последовательности \u003c, \u003e, \u0026.
Вставка скрипта с уникальным id в дерево компонентов. Детерминированное обновление содержимого при смене маршрута.
Проверка синтаксиса и обязательных полей через Google Rich Results Test API или Playwright-тесты в пайплайне CI/CD.
Почему формат JSON-LD признан оптимальным для современных React SPA по сравнению с инлайн-разметкой Microdata?
Product, Offer, AggregateRating формируют расширенные сниппеты в Google с отображением цены, валюты, статуса наличия (InStock) и звездочек рейтинга.TechArticle, SoftwareSourceCode и BreadcrumbList обеспечивают точную иерархическую навигационную цепочку (хлебные крошки) в поисковой выдаче и приоритетное цитирование в Google AI Overviews и Perplexity.FAQPage разворачивает интерактивный аккордеон с ответами прямо под ссылкой на сайт в результатах выдачи.Реализуем типобезопасный, защищенный от XSS компонент для внедрения Schema.org в React SPA.
/**
* Безопасно сериализует объект данных в строку JSON для вставки в HTML-тег <script>.
* Нейтрализует символы <, >, & и строковые терминаторы JavaScript,
* предотвращая уязвимости типа Cross-Site Scripting (XSS).
*/
export function serializeSafeJsonLd(data: unknown): string {
const jsonString = JSON.stringify(data, null, process.env.NODE_ENV === 'development' ? 2 : 0);
if (!jsonString) {
return '{}';
}
// Экранируем спецсимволы по спецификации HTML5/W3C
return jsonString
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028') // Line Separator
.replace(/\u2029/g, '\\u2029'); // Paragraph Separator
}Пакет schema-dts предоставляет строгие типы TypeScript для всего словаря 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;
}
/**
* Универсальный компонент инъекции JSON-LD структурированных данных.
* Гарантирует 100% строгую типизацию Schema.org и защиту от 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. Формируем схему Product
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. Формируем схему BreadcrumbList
const breadcrumbSchema: WithContext<BreadcrumbList> = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Главная',
item: 'https://agenticlab.dev',
},
{
'@type': 'ListItem',
position: 2,
name: 'Каталог',
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">
{/* Внедрение структурированных данных */}
<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>
);
};Для валидации схем в CI/CD можно использовать официальный валидатор Google через cURL или Playwright:
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' });
// Извлекаем все блоки JSON-LD из отрендеренного DOM
const jsonLdScripts = await page.locator('script[type="application/ld+json"]').allInnerTexts();
if (jsonLdScripts.length === 0) {
throw new Error(`[Schema Error]: На странице ${url} отсутствуют блоки JSON-LD`);
}
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]: Отсутствуют обязательные ключи @context или @type`);
}
console.info(`[Schema Valid]: Успешно спарсен @type: ${String(parsed['@type'])}`);
} catch (err: unknown) {
throw new Error(`[Schema Error]: Невалидный JSON: ${err instanceof Error ? err.message : 'Unknown'}`);
}
}
await browser.close();
}Какая уязвимость возникает при прямой вставке JSON.stringify(schema) в тег script и как ее нейтрализовать?
Использование <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} /> без экранирования. Если злоумышленник внедрит в поле названия статьи строку </script><script>stealTokens()</script>, парсер браузера преждевременно закроет тег данных и исполнит сторонний JS-код.
Решение: Всегда применяйте функцию санитизации serializeSafeJsonLd, заменяющую символы <, >, & на их Unicode-эквиваленты (\u003c, \u003e, \u0026).
Поисковые системы строго наказывают сайты за расхождения: если в JSON-LD указана цена 99 USD и рейтинг 5.0, а в видимом интерфейсе товара цена 199 USD и рейтинг отсутствует, Google расценивает это как манипуляцию (Spammy Structured Data) и накладывает ручные санкции (Manual Action), отключая показ расширенных сниппетов для всего домена.
Решение: Формируйте JSON-LD строго из тех же самых пропсов и DTO, которые используются для рендеринга JSX-разметки страницы.
Если генерировать теги <script type="application/ld+json"> с динамическими случайными ключами или без id, при переходах между страницами в чистом SPA старые скрипты могут накапливаться в document.head или теле документа. Поисковый краулер на второй фазе рендеринга WRS обнаружит десятки противоречащих друг другу схем Product на одной странице.
Решение: Присваивайте элементам фиксированные детерминированные id (например, id="product-jsonld"), чтобы React корректно обновлял существующий узел вместо добавления нового.
Передача локализованных строк цен ("1 299,50 руб.") или некорректных форматов дат ("12 мая 2026"). Спецификация Schema.org строго требует точку в качестве десятичного разделителя ("1299.50") и даты в формате ISO 8601 ("2026-05-12T10:00:00Z").
Решение: Применяйте методы toISOString() для дат и toFixed(2) для числовых значений цен.