Семантическая разметка по стандарту 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)Ключевые понятия архитектуры семантических графов:
Серверный компонент страницы считывает строго типизированные данные из БД или микросервиса.
Создаются объекты Schema.org с проверкой типов на этапе компиляции TypeScript без использования any.
Все узлы (Organization, WebSite, Breadcrumbs, Product) объединяются через детерминированные @id ссылки.
Строка JSON экранируется от XSS (замена < на \u003c) и встраивается в тег <script type='application/ld+json'>.
Автоматизированные тесты проверяют валидность графа перед слиянием Pull Request в основную ветку.
Почему в современной архитектуре Next.js предпочтительнее объединять сущности в единый семантический граф @graph на сервере, вместо множественных клиентских тегов <script>?
Product + AggregateOffer + MerchantReturnPolicy + ShippingDetails для получения товарных сниппетов (Product Rich Snippets) и автоматической синхронизации с Google Merchant Center.TechArticle с профилями авторов Person (со ссылками sameAs на GitHub, LinkedIn, ORCID) и организацией Organization для подтверждения критериев E-E-A-T (Experience, Expertise, Authoritativeness, Trustworthiness).Organization со всеми официальными соцсетями, идентификаторами ИНН/OGRN (через taxID), контактами служб поддержки и географическими координатами офисов.Course, уроков, видеоматериалов VideoObject и интерактивных вопросников FAQPage для ранжирования в каруселях образовательного контента Google.При внедрении JSON-LD через свойство dangerouslySetInnerHTML в React существует риск уязвимости Cross-Site Scripting (XSS). Если в текст отзыва или описание товара злоумышленник внедрит строку </script><script>alert(1)</script>, HTML-парсер браузера завершит чтение блока данных и исполнит внедренный скрипт.
Для предотвращения атаки символы < заменяются их безопасными Unicode-эквивалентами \u003c.
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,
Product,
BreadcrumbList,
TechArticle,
WithContext,
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: ['Russian', 'English'],
},
};
}
/**
* Создает глобальный узел Веб-сайта (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,
WEBSITE_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: 'RUB',
price: 149000,
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('ru-RU')} {product.currency}
</div>
</main>
</>
);
}Валидация сформированного графа осуществляется на двух уровнях:
В пайплайне автоматизированного тестирования (Vitest) проверка схемы реализуется путем парсинга HTML-разметки:
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' });
});
});С какой критической угрозой безопасности и поисковых санкций сопряжено некорректное внедрение данных в тег application/ld+json?
Если в пользовательский отзыв или название продукта попадает строка </script>, браузер при разборе HTML считает тег скрипта закрытым и начинает выполнять следующий текст как JS-код в контексте страницы.
// ❌ АНТИПАТТЕРН: Критическая уязвимость к XSS-инъекциям!
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>Решение: Обязательно экранируйте спецсимволы <, >, & в JSON-строке с помощью регулярного выражения или используйте утилиту sanitizeJsonLd.
Разработчики помещают отдельный тег <script type="application/ld+json"> в шапку (для Organization), еще один в футер (для WebSite) и третий на саму страницу (для Product). Поисковый робот видит разрозненные острова данных. Если название или контакты компании в разных блоках незначительно расходятся, Google аннулирует доверие к данным и лишает сайт расширенных сниппетов.
Решение: Объединяйте все сущности в единый массив @graph с детерминированными URI в свойстве @id.
Помещение логики генерации Schema.org в клиентский компонент. Это вынуждает сборщик Next.js включать тяжелые словари типов и структуры данных в клиентский JavaScript-бандл. Клиент тратит вычислительные ресурсы на сериализацию и монтирование скрипта в DOM, а поисковые краулеры первой фазы (без исполнения JS) не получают разметку в первичном HTML-ответе.
Решение: Рендерите JsonLdGraph исключительно в React Server Components (RSC).
Указание в микроразметке цены 9 990 ₽ или рейтинга 5.0 (450 отзывов), в то время как на видимой части страницы указана цена 14 990 ₽ или отзывы отсутствуют. Данное действие расценивается Google как манипуляция и влечет за собой ручные санкции (Structured Data Manual Action Penalty) с полным исключением домена из Rich Results.
Решение: Передавайте в сущности Schema.org те же самые серверные данные из базы, которые используются для отрисовки JSX-интерфейса.