Пошук уроків, статей та іншого контенту
Налаштуєте метадані, canonical URL і структуровані дані для сторінок із динамічними сегментами маршруту.
Динамічний маршрут має сегмент, який змінюється залежно від даних:
app/products/[slug]/page.tsxДля URL:
/products/mechanical-keyboard
/products/usb-c-hubзначення slug буде різним. SEO-налаштування також потрібно генерувати на основі цього значення:
<title> повинен містити назву конкретного товару;
description має описувати саме цю сторінку;
canonical URL повинен посилатися на канонічну адресу конкретного ресурсу;
структуровані дані повинні містити правильний тип і властивості поточного ресурсу.
У Next.js App Router для цього використовують:
generateMetadata для динамічних метаданих;
metadataBase для побудови абсолютних URL;
JSON-LD для структурованих даних.
Спочатку вкажемо базову адресу сайту в кореневому layout:
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL(
process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000",
),
title: {
default: "Магазин техніки",
template: "%s | Магазин техніки",
},
description: "Інтернет-магазин техніки та аксесуарів",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="uk">
<body>{children}</body>
</html>
);
}Для production у змінній середовища потрібно вказати справжню адресу:
NEXT_PUBLIC_SITE_URL=https://example.commetadataBase дозволяє використовувати відносні URL у метаданих. Наприклад:
alternates: {
canonical: `/products/${slug}`,
}Next.js перетворить його на:
https://example.com/products/mechanical-keyboardБез metadataBase Next.js не зможе коректно побудувати абсолютний canonical URL з відносного шляху.
generateMetadataДля статичної сторінки можна експортувати об'єкт metadata. Але для Dynamic Route метадані залежать від slug, тому використовують асинхронну функцію generateMetadata.
Розглянемо повний приклад сторінки товару.
// app/products/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
type Product = {
slug: string;
name: string;
description: string;
price: number;
currency: string;
image: string;
inStock: boolean;
};
const products: Product[] = [
{
slug: "mechanical-keyboard",
name: "Механічна клавіатура Keychron K2",
description:
"Бездротова механічна клавіатура з підсвічуванням і перемикачами Gateron.",
price: 3499,
currency: "UAH",
image: "https://example.com/images/mechanical-keyboard.jpg",
inStock: true,
},
{
slug: "usb-c-hub",
name: "USB-C хаб 7-в-1",
description:
"Компактний USB-C хаб із HDMI, USB 3.0, Ethernet і підтримкою карт пам'яті.",
price: 1299,
currency: "UAH",
image: "https://example.com/images/usb-c-hub.jpg",
inStock: false,
},
];
async function getProduct(slug: string): Promise<Product | undefined> {
return products.find((product) => product.slug === slug);
}
type ProductPageProps = {
params: Promise<{
slug: string;
}>;
};
export async function generateMetadata({
params,
}: ProductPageProps): Promise<Metadata> {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) {
return {
title: "Товар не знайдено",
description: "Запитаний товар не знайдено.",
robots: {
index: false,
follow: false,
},
};
}
return {
title: product.name,
description: product.description,
alternates: {
canonical: `/products/${product.slug}`,
},
openGraph: {
title: product.name,
description: product.description,
url: `/products/${product.slug}`,
type: "website",
images: [
{
url: product.image,
alt: product.name,
},
],
},
};
}
function ProductJsonLd({ product }: { product: Product }) {
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
description: product.description,
image: [product.image],
offers: {
"@type": "Offer",
url: `https://example.com/products/${product.slug}`,
price: product.price,
priceCurrency: product.currency,
availability: product.inStock
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
},
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
// Екрануємо символ "<", щоб дані не могли завершити тег script.
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
}}
/>
);
}
export default async function ProductPage({ params }: ProductPageProps) {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) {
notFound();
}
return (
<main>
<ProductJsonLd product={product} />
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>
Ціна: {product.price} {product.currency}
</p>
<p>{product.inStock ? "Є в наявності" : "Немає в наявності"}</p>
</article>
</main>
);
}У сучасних версіях Next.js params для асинхронних сторінок передається як Promise, тому його потрібно отримати через await params.
Якщо проєкт використовує версію Next.js, де params є звичайним об'єктом, тип і код будуть іншими:
type ProductPageProps = {
params: {
slug: string;
};
};
export default function ProductPage({ params }: ProductPageProps) {
const { slug } = params;
return <main>{slug}</main>;
}У generateMetadata назва товару стає значенням title:
return {
title: product.name,
description: product.description,
};Якщо в layout налаштовано шаблон:
title: {
default: "Магазин техніки",
template: "%s | Магазин техніки",
},фактичний заголовок документа буде:
Механічна клавіатура Keychron K2 | Магазин технікиДля description використовуйте короткий, змістовний опис сторінки. Не варто генерувати його лише з slug, наприклад:
description: `Сторінка товару ${slug}`,Такий опис не пояснює користувачеві, що саме містить сторінка. Краще брати опис із даних товару або окреме SEO-поле в базі даних.
Якщо товар не знайдено, сторінка повертає notFound(). Для метаданих такої сторінки корисно заборонити індексацію:
return {
title: "Товар не знайдено",
robots: {
index: false,
follow: false,
},
};Основний виклик notFound() у компоненті сторінки додатково покаже спеціальну 404-сторінку.
Canonical URL повідомляє пошуковим системам, яка адреса є основною для поточного вмісту:
alternates: {
canonical: `/products/${product.slug}`,
},За наявності metadataBase це створить у HTML:
<link
rel="canonical"
href="https://example.com/products/mechanical-keyboard"
/>Canonical повинен:
відповідати саме поточному товару;
використовувати стабільний формат URL;
не містити параметрів сортування, фільтрації або трекінгу;
вести на URL, який повертає успішну відповідь і доступний для індексації.
Наприклад, для сторінки:
/products/mechanical-keyboardcanonical не повинен випадково посилатися на:
/products/usb-c-hubабо на URL із параметрами:
/products/mechanical-keyboard?utm_source=newsletterМетадані допомагають описати документ, а структуровані дані пояснюють пошуковій системі тип вмісту. Для сторінки товару використовується Schema.org-тип Product.
Мінімальний приклад:
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
description: product.description,
image: [product.image],
offers: {
"@type": "Offer",
url: `https://example.com/products/${product.slug}`,
price: product.price,
priceCurrency: product.currency,
availability: product.inStock
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
},
};Потім об'єкт потрібно помістити в script:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
}}
/>application/ld+json означає, що вміст скрипта є даними JSON-LD, а не JavaScript-кодом для виконання.
JSON.stringify перетворює об'єкт у рядок, але значення з бази даних можуть містити спеціальні символи. Символ < може бути використаний для передчасного завершення HTML-елемента script.
Тому безпечніше екранувати його:
JSON.stringify(jsonLd).replace(/</g, "\\u003c")Не вставляйте в JSON-LD необроблений HTML і не використовуйте dangerouslySetInnerHTML для даних, які не були серіалізовані та перевірені.
У реальному застосунку getProduct зазвичай виконує запит до API або бази даних:
async function getProduct(slug: string): Promise<Product | undefined> {
const response = await fetch(
`${process.env.API_URL}/products/${encodeURIComponent(slug)}`,
);
if (!response.ok) {
return undefined;
}
return response.json();
}Одна й та сама функція отримання даних може викликатися в generateMetadata і в компоненті сторінки:
export async function generateMetadata({ params }: ProductPageProps) {
const { slug } = await params;
const product = await getProduct(slug);
// Формування метаданих
}
export default async function ProductPage({ params }: ProductPageProps) {
const { slug } = await params;
const product = await getProduct(slug);
// Формування сторінки
}Next.js може кешувати однакові запити fetch, якщо вони мають однаковий URL і параметри. Проте поведінка кешування залежить від способу отримання даних і налаштувань проєкту. Якщо дані змінюються часто, потрібно окремо налаштувати актуальність кешу.
Важливо, щоб URL у JSON-LD збігався з canonical URL. Не використовуйте різні домени або різні варіанти шляху для одного ресурсу.
Open Graph-метадані потрібні для коректного представлення сторінки під час поширення в соціальних мережах і месенджерах:
openGraph: {
title: product.name,
description: product.description,
url: `/products/${product.slug}`,
type: "website",
images: [
{
url: product.image,
alt: product.name,
},
],
},Для товару можна використовувати type: "website", якщо сторінка не потребує іншого типу в межах конфігурації вашого застосунку. Головне — передавати унікальні назву, опис, адресу й зображення для кожного динамічного сегмента.
JSON-LD можна розмістити безпосередньо в компоненті сторінки:
export default async function ProductPage({ params }: ProductPageProps) {
const product = await getProduct((await params).slug);
if (!product) {
notFound();
}
return (
<main>
<ProductJsonLd product={product} />
<h1>{product.name}</h1>
</main>
);
}Оскільки сторінка за замовчуванням є Server Component, JSON-LD буде сформовано на сервері разом із HTML. Для SEO не потрібно створювати окремий клієнтський компонент або чекати завантаження даних у браузері.
Після запуску застосунку перевірте сторінку конкретного ресурсу, наприклад:
/products/mechanical-keyboardУ згенерованому HTML потрібно знайти:
title із назвою товару;
meta name="description";
link rel="canonical";
Open Graph-метадані;
script type="application/ld+json" із даними саме цього товару.
Також перевірте окремо:
сторінку з іншим slug;
неіснуючий slug;
товар без зображення;
товар, якого немає в наявності;
production-домен замість localhost.
export const metadata: Metadata = {
title: "Товар",
};Такий варіант не описує конкретні сторінки Dynamic Route. Для даних, що залежать від slug, використовуйте generateMetadata.
slugНе беріть значення з іншого джерела, ніж дані поточного товару:
canonical: `/products/${someOtherSlug}`,Canonical має бути пов'язаний із ресурсом, який фактично відображається.
metadataBaseВідносний canonical без metadataBase може бути неповним або некоректним для пошукових систем.
export const metadata: Metadata = {
alternates: {
canonical: "/products/example",
},
};Налаштуйте metadataBase у кореневому layout.
slug замість реальних данихdescription: `Product ${slug}`,Для SEO краще використовувати назву й опис із бази даних або CMS.
Не створюйте структуровані дані, якщо товар не знайдений. Спочатку перевірте результат запиту, а потім викликайте notFound() або поверніть метадані для 404-сторінки.
Назва, ціна, валюта, доступність і зображення в JSON-LD повинні відповідати інформації, яку користувач бачить на сторінці. Не додавайте в структуровані дані властивості, яких немає в реальному вмісті.
Переконайтеся, що canonical, openGraph.url і offers.url використовують один production-домен. Значення http://localhost:3000 не повинно потрапити в production-метадані.
Для динамічних метаданих у Next.js використовуйте generateMetadata.
Отримуйте дані за значенням динамічного сегмента slug.
Вказуйте metadataBase у кореневому layout.
Формуйте canonical URL для конкретного ресурсу через alternates.canonical.
Додавайте унікальні title, description і Open Graph-дані для кожної сторінки.
Для сторінок товарів використовуйте JSON-LD зі Schema.org-типом Product.
Не створюйте SEO-дані для неіснуючих ресурсів.
Перевіряйте, щоб canonical, JSON-LD і видимий вміст описували одну й ту саму сторінку.