Пошукові алгоритми сучасного вебу оцінюють не лише ключові слова, а й семантичну структуру даних сторінки: сутності (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 В SPA |
+-------------------------------------------------------------------------+
[ React SPA Компонент ]
|
| 1. Дані сутності з API (Типізований DTO)
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):
JSON-LD формує зв'язний граф об'єктів за допомогою ключа @id. Це дає змогу посилатися на сутність організації або автора з кількох незалежних схем без дублювання інформації.<title> і <meta>, React не завжди виконує автоматичний мерж вмісту довільних тегів <script>. Для керування JSON-LD в SPA використовується детермінований id або спеціалізований компонент, який контролює заміну текстового вмісту єдиного скрипт-вузла при зміні маршруту.</script><script>alert(1)</script>, парсер HTML браузера завершить читання поточного скрипту на закриваючому тезі та виконає шкідливий код. Тому всі небезпечні HTML-символи всередині JSON екрануються за допомогою Unicode-ескейп-послідовностей (\u003c, \u003e, \u0026).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-пайплайні перед виходом у продакшн:
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();
}Використання <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"> без детермінованих ідентифікаторів, при переходах між сторінками в чистому SPA старі скрипти можуть накопичуватися в DOM. Пошуковий краулер на другій фазі рендерингу 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) для числових значень цін.
Визначення структури схеми за допомогою словників schema-dts (TechArticle, Product, FAQPage) без використання типу any.
Перетворення об'єкта в безпечний JSON із заміною <, >, & на Unicode-послідовності \u003c, \u003e, \u0026.
Вставка скрипта з унікальним id у дерево компонентів. Детерміноване оновлення вмісту при зміні маршруту.
Перевірка синтаксису та обов'язкових полів через Google Rich Results Test API або Playwright-тести в пайплайні CI/CD.