Семантична розмітка за стандартом Schema.org є фундаментальним мостом між неструктурованим кодом HTML та алгоритмами штучного інтелекту й графами знань пошукових систем (Google Knowledge Graph). Структуровані дані дозволяють пошуковим краулерам однозначно ідентифікувати бізнес-сутності, авторів, комерційні пропозиції, навігаційні ланцюжки та експертний контент, генеруючи розширені результати пошукової видачі (Rich Results).
Історично у фронтенд-екосистемі React розмітка впроваджувалася через клієнтські бібліотеки (react-helmet, next-seo) або маніпуляції з DOM у хуках useEffect. Такий підхід має три системні архітектурні дефекти:
JSON.stringify() без екранування спецсимволів HTML дозволяє зловмисникам впроваджувати довільний JavaScript-код через закриваючий тег </script>.<script type="application/ld+json"> для кожної окремої частини сторінки. У результаті пошукова система бачить не цілісний граф знань, а розрізнені фрагменти без явного зв'язку між товаром, продавцем, брендом і глобальним веб-сайтом.Архітектура React Server Components (RSC) у Next.js 15 усуває ці проблеми за рахунок парадигми Zero Client-Side JS. Побудова графа сутностей Schema.org виконується виключно в ізольованому серверному середовищі з використанням бібліотеки суворої типізації schema-dts.
У браузер клієнта віддається лише чистий, статично серіалізований блок HTML, що не потребує жодного байта JavaScript-коду для гідратації і не навантажує головний потік браузера.
Замість генерації множини незалежних блоків JSON-LD сучасний стандарт веб-семантики вимагає формування єдиного графа сутностей через властивість @graph.
Кожен вузол графа має унікальний глобальний ідентифікатор @id, оформлений у вигляді канонічного URI з фрагментом-якорем (хешем). Вузли посилаються один на одного за цими ключами, формуючи орієнтований граф (Directed Graph), що точно відображає онтологію застосунку.
+-----------------------------------------------------------------------------------------+
| СЕМАНТИЧНИЙ ГРАФ @graph |
+-----------------------------------------------------------------------------------------+
[Organization] (https://agenticlab.dev/#organization)
▲
│ publisher / brand
│
[WebSite] (https://agenticlab.dev/#website)
▲
│ isPartOf
│
[WebPage] (https://agenticlab.dev/products/server-v5#webpage)
│
├─────────────────────────────┬─────────────────────────────┐
│ breadcrumb │ mainEntity │ primaryImageOfPage
▼ ▼ ▼
[BreadcrumbList] [Product] [ImageObject]
(#breadcrumb) (#product) (#main-image)
│
├─────────────────────────────┐
│ offers │ aggregateRating
▼ ▼
[AggregateOffer] [AggregateRating]
(#offers) (#rating)Ключові поняття архітектури семантичних графів:
Product + AggregateOffer + MerchantReturnPolicy + ShippingDetails для отримання розширених сніпетів товарів та синхронізації з Google Merchant Center.TechArticle із профілями авторів Person (з посиланнями sameAs на GitHub, LinkedIn, ORCID) та видавцем Organization для підтвердження критеріїв E-E-A-T.Organization з усіма офіційними соціальними мережами, кодами платника податків (taxID), контактами підтримки та геокоординатами офісів.Course, уроків, відеоматеріалів VideoObject та інтерактивних відповідей FAQPage для ранжування в освітніх каруселях Google.При впровадженні JSON-LD через властивість dangerouslySetInnerHTML у React існує ризик вразливості Cross-Site Scripting (XSS). Якщо в текст відгуку або назву товару потрапить рядок </script><script>alert(1)</script>, HTML-парсер браузера завчасно завершить зчитування тегу та виконає впроваджений скрипт.
Для запобігання атаці символи <, >, & замінюються на їхні безпечні еквіваленти Unicode \u003c, \u003e, \u0026.
import type { Graph, Thing, WithContext } from 'schema-dts';
interface JsonLdGraphProps {
/** Вузли семантичного графа Schema.org */
nodes: Thing[];
}
/**
* Санітизує JSON рядок, замінюючи небезпечні HTML-символи на Unicode-послідовності.
* Запобігає XSS-ін'єкціям через передчасне закриття тегу </script>.
*/
function sanitizeJsonLd(data: WithContext<Graph>): string {
return JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
}
/**
* Server Component для безпечного впровадження семантичного графа Schema.org.
* Zero Client-Side JS: Не додає жодного байта до клієнтського JavaScript-бандла!
*/
export function JsonLdGraph({ nodes }: JsonLdGraphProps) {
// Формування кореневого графа Linked Data
const graph: WithContext<Graph> = {
'@context': 'https://schema.org',
'@graph': nodes,
};
const safeJson = sanitizeJsonLd(graph);
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: safeJson }}
/>
);
}Створимо модуль для формування глобальних сутностей зі суворим контролем типів:
import type {
Organization,
WebSite,
BreadcrumbList,
Thing,
} from 'schema-dts';
const SITE_ORIGIN = 'https://agenticlab.dev';
export const ORGANIZATION_ID = `${SITE_ORIGIN}/#organization`;
export const WEBSITE_ID = `${SITE_ORIGIN}/#website`;
/**
* Створює глобальний вузол Організації (Organization)
*/
export function createOrganizationEntity(): Organization {
return {
'@type': 'Organization',
'@id': ORGANIZATION_ID,
name: 'Fullstack Agentic Lab Academy',
url: SITE_ORIGIN,
logo: {
'@type': 'ImageObject',
'@id': `${SITE_ORIGIN}/#logo`,
url: `${SITE_ORIGIN}/assets/logo.png`,
caption: 'Fullstack Agentic Lab Logo',
},
sameAs: [
'https://github.com/agenticlab-dev',
'https://twitter.com/agenticlab_dev',
'https://linkedin.com/company/agenticlab',
],
contactPoint: {
'@type': 'ContactPoint',
contactType: 'technical support',
email: '[email protected]',
availableLanguage: ['Ukrainian', 'English', 'Spanish'],
},
};
}
/**
* Створює глобальний вузол Веб-сайту (WebSite), пов'язаний з Організацією
*/
export function createWebSiteEntity(): WebSite {
return {
'@type': 'WebSite',
'@id': WEBSITE_ID,
url: SITE_ORIGIN,
name: 'Fullstack Agentic Lab',
publisher: {
'@id': ORGANIZATION_ID,
},
potentialAction: {
'@type': 'SearchAction',
target: `${SITE_ORIGIN}/search?q={search_term_string}`,
'query-input': 'required name=search_term_string',
},
};
}
interface BreadcrumbItemInput {
name: string;
path: string;
}
/**
* Створює вузол ланцюжка навігації (BreadcrumbList)
*/
export function createBreadcrumbsEntity(items: BreadcrumbItemInput[]): BreadcrumbList {
return {
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: `${SITE_ORIGIN}${item.path}`,
})),
};
}Ось повноцінний серверний компонент сторінки товару, де всі сутності інтегруються в єдиний граф @graph:
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import type { Product, AggregateOffer, AggregateRating } from 'schema-dts';
import { JsonLdGraph } from '@/components/seo/JsonLdGraph';
import {
createOrganizationEntity,
createWebSiteEntity,
createBreadcrumbsEntity,
ORGANIZATION_ID,
} from '@/lib/seo/schema-factory';
interface ProductPageProps {
params: Promise<{ slug: string }>;
}
interface ProductData {
id: string;
slug: string;
name: string;
description: string;
sku: string;
gtin13: string;
currency: string;
price: number;
ratingValue: number;
reviewCount: number;
imageUrl: string;
}
async function fetchProductData(slug: string): Promise<ProductData | null> {
if (slug !== 'enterprise-server-license') return null;
return {
id: 'prod_98412',
slug: 'enterprise-server-license',
name: 'Ліцензія Fullstack Agentic Lab Enterprise Core Server',
description: 'Високопродуктивний серверний рантайм із підтримкою Next.js 15 та PPR.',
sku: 'AG-SRV-2026',
gtin13: '4601234567890',
currency: 'UAH',
price: 74900,
ratingValue: 4.95,
reviewCount: 48,
imageUrl: 'https://agenticlab.dev/images/products/server.png',
};
}
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
const { slug } = await params;
const product = await fetchProductData(slug);
if (!product) return { title: 'Товар не знайдено' };
return {
title: `${product.name} — Fullstack Agentic Lab`,
description: product.description,
alternates: {
canonical: `https://agenticlab.dev/products/${product.slug}`,
},
};
}
export default async function ProductDetailsPage({ params }: ProductPageProps) {
const { slug } = await params;
const product = await fetchProductData(slug);
if (!product) {
notFound();
}
const pageCanonicalUrl = `https://agenticlab.dev/products/${product.slug}`;
const productId = `${pageCanonicalUrl}#product`;
// 1. Вузол Товар (Product)
const productEntity: Product = {
'@type': 'Product',
'@id': productId,
name: product.name,
description: product.description,
sku: product.sku,
gtin13: product.gtin13,
image: product.imageUrl,
brand: {
'@id': ORGANIZATION_ID, // Посилання на глобальну організацію
},
offers: {
'@type': 'AggregateOffer',
priceCurrency: product.currency,
lowPrice: product.price,
highPrice: product.price * 1.2,
offerCount: 1,
offers: [
{
'@type': 'Offer',
url: pageCanonicalUrl,
price: product.price,
priceCurrency: product.currency,
availability: 'https://schema.org/InStock',
itemCondition: 'https://schema.org/NewCondition',
seller: {
'@id': ORGANIZATION_ID,
},
},
],
} as AggregateOffer,
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: product.ratingValue,
reviewCount: product.reviewCount,
bestRating: 5,
worstRating: 1,
} as AggregateRating,
};
// 2. Вузол Хлібні крихти
const breadcrumbsEntity = createBreadcrumbsEntity([
{ name: 'Головна', path: '/' },
{ name: 'Каталог', path: '/products' },
{ name: product.name, path: `/products/${product.slug}` },
]);
// 3. Об'єднання всіх вузлів у єдину онтологію
const schemaNodes = [
createOrganizationEntity(),
createWebSiteEntity(),
breadcrumbsEntity,
productEntity,
];
return (
<>
{/* Семантичний граф впроваджується на рівні RSC з нульовим клієнтським JS */}
<JsonLdGraph nodes={schemaNodes} />
<main className="max-w-5xl mx-auto px-4 py-12 space-y-6">
<h1 className="text-3xl font-extrabold text-slate-100">{product.name}</h1>
<p className="text-lg text-slate-300">{product.description}</p>
<div className="text-2xl font-bold text-accent-cyan">
{product.price.toLocaleString('uk-UA')} {product.currency}
</div>
</main>
</>
);
}Перевірка коректності графа здійснюється за двома напрямками:
У пайплайні CI/CD валідація реалізується через автоматичні тести:
import { describe, it, expect } from 'vitest';
import type { Graph, Product } from 'schema-dts';
describe('Schema.org Graph Integrity', () => {
it('should generate valid interconnected @graph with deterministic @id URIs', () => {
const rawJsonLd = `{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://agenticlab.dev/#organization",
"name": "Fullstack Agentic Lab"
},
{
"@type": "Product",
"@id": "https://agenticlab.dev/products/server#product",
"name": "Enterprise Server",
"brand": { "@id": "https://agenticlab.dev/#organization" }
}
]
}`;
const parsed = JSON.parse(rawJsonLd) as Graph;
const nodes = parsed['@graph'];
expect(nodes).toBeDefined();
expect(nodes).toHaveLength(2);
const product = nodes?.find((n) => n['@type'] === 'Product') as Product;
expect(product).toBeDefined();
expect(product.brand).toEqual({ '@id': 'https://agenticlab.dev/#organization' });
});
});Якщо у відгук клієнта або назву товару потрапляє рядок </script>, браузер під час розбору HTML сприймає це як завершення тегу і починає виконувати наступний текст як виконуваний JS-код.
// ❌ АНТИПАТТЕРН: Критична вразливість до XSS-ін'єкцій!
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>Вирішення: Обов'язково екрануйте спецсимволи <, >, & у JSON-рядку Unicode-еквівалентами за допомогою утиліти sanitizeJsonLd.
Розміщення окремих тегів <script type="application/ld+json"> у різних компонентах сторінки. Пошуковий робот сприймає їх як ізольовані острови даних. Якщо назва компанії чи URL дещо відрізняються, Google анулює довіру до розмітки та позбавляє сайт розширених сніпетів.
Вирішення: Об'єднуйте всі сутності в єдиний масив @graph із детермінованими URI у властивості @id.
Розміщення логіки генерації Schema.org у клієнтському компоненті змушує Next.js включати важкі словники типів у клієнтський JavaScript-бандл. Клієнт витрачає ресурси на серіалізацію, а пошукові боти першої фази (без виконання JS) не отримують розмітку в початковому HTML.
Вирішення: Рендеріть JsonLdGraph виключно в React Server Components (RSC).
Вказання в JSON-LD заниженої ціни або завищеного рейтингу, яких немає у видимому інтерфейсі. Пошукові алгоритми Google трактують це як маніпуляцію і накладають ручні штрафні санкції (Structured Data Manual Action Penalty), повністю виключаючи сайт із Rich Results.
Вирішення: Передавайте в об'єкти Schema.org ті ж самі серверні дані з бази, які використовуються для відображення JSX.
Серверний компонент сторінки отримує типізовані дані з бази даних або мікросервісу.
Об'єкти Schema.org синтезуються з перевіркою типів компілятором TypeScript без any.
Усі вузли (Organization, WebSite, Breadcrumbs, Product) об'єднуються через детерміновані @id посилання.
Рядок JSON екранується від XSS (< замінюється на \u003c) та вбудовується в статичний тег.
Автоматизовані тести перевіряють валідність графа перед злиттям Pull Request у main.