У класичній клієнтській архітектурі Single Page Application (SPA) на базі React пошукова оптимізація (SEO) становила фундаментальну проблему: пошукові краулери (Googlebot, Bingbot) отримували порожній HTML-каркас <div id="root"></div> і були змушені запускати ресурсномістку фазу рендерингу JavaScript (Web Rendering Service) для вилучення текстового контенту та метатегів. Це призводило до тривалих затримок індексації та нестабільності позицій у пошуковій видачі.
Next.js App Router усуває цю проблему завдяки архітектурі React Server Components (RSC) та вбудованому Metadata API. Пошукові роботи та парсери соціальних мереж отримують повністю сформований валідний HTML-документ із попередньо заповненими тегами <title>, <meta>, картками попереднього перегляду OpenGraph (OG), директивами Canonical URL та семантичною мікророзміткою JSON-LD.
Metadata API у Next.js надає суворо типізований, композиційний інтерфейс, який автоматично забезпечує ієрархічне каскадування та злиття метаданих між вкладеними layouts та сторінками без колізій у тегу <head>.
Metadata API виконується виключно на стороні сервера до відправлення початкового чанка HTML клієнту. Модулі маршрутів (layout.tsx або page.tsx) експортують або статичний конфігураційний об'єкт metadata, або асинхронну функцію-генератор generateMetadata.
Метадані каскадуються деревом маршрутів згори вниз:
app/layout.tsx (наприклад, дефолтний favicon, локаль, шаблон заголовка), автоматично застосовуються до всіх дочірніх сторінок застосунку.page.tsx визначає властивість, уже оголошену в батьківському layout (наприклад, description), значення дочірнього сегмента заміщує батьківське.openGraph.images) можна об'єднувати з батьківськими визначеннями за допомогою помічника parent: ResolvingMetadata всередині generateMetadata./og.png) та канонічні URL автоматично трансформуються в абсолютні адреси на основі значення metadataBase, заданого в кореневому layout.Пошуковий краулер або браузер запитує URL (наприклад, /blog/rsc-deep-dive). Next.js визначає дерево layout до конкретного page.tsx.
Парсяться статичні експорти metadata з app/layout.tsx та проміжних layouts, формуючи базові налаштування та title.template.
Функція generateMetadata виконується паралельно, отримуючи необхідні дані з вашого DAL/ORM з автоматичною мемоїзацією запитів.
Next.js об'єднує батьківські та дочірні метадані. Відносні шляхи до зображень та канонічні посилання перетворюються на абсолютні URL.
Сформований блок <head> передається клієнту в першому байтовому потоці до завершення стрімінгу тіла сторінки (RSC Streaming).
metadataBase#Кореневий файл app/layout.tsx визначає базовий домен застосунку, шаблони заголовків за замовчуванням та глобальні правила індексації пошуковими роботами:
import type { Metadata } from 'next';
export const metadata: Metadata = {
// Обов'язково: базовий домен для коректного резолву відносних шляхів в абсолютні URL
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'https://agenticlab.dev'),
title: {
template: '%s | Fullstack Agentic Lab',
default: 'Fullstack Agentic Lab — Освітня платформа для інженерів',
},
description: 'Поглиблені курси з Next.js, React, архітектури баз даних та агентних систем.',
keywords: ['Next.js 15', 'React 19', 'TypeScript', 'App Router', 'Fullstack'],
authors: [{ name: 'Команда Fullstack Agentic Lab', url: 'https://agenticlab.dev' }],
creator: 'Fullstack Agentic Lab',
openGraph: {
type: 'website',
locale: 'uk_UA',
url: 'https://agenticlab.dev',
siteName: 'Fullstack Agentic Lab',
title: 'Fullstack Agentic Lab — Освітня платформа для інженерів',
description: 'Високопродуктивна програма навчання для досвідчених інженерів.',
images: [
{
url: '/og-default.png', // Перетворюється на https://agenticlab.dev/og-default.png
width: 1200,
height: 630,
alt: 'Прев’ю платформи Fullstack Agentic Lab',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Fullstack Agentic Lab',
description: 'Архітектура сучасного стека Next.js, React та AI-агентів',
images: ['/og-default.png'],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="uk">
<body>{children}</body>
</html>
);
}generateMetadata)#У Next.js 15 та 16 параметри маршруту (params) та параметри рядка запиту (searchParams) є асинхронними Promise-об'єктами.
import type { Metadata, ResolvingMetadata } from 'next';
import { notFound } from 'next/navigation';
interface BlogPostPageProps {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
// Запит через шар доступу до даних (DAL) з дедуплікацією
async function getPostBySlug(slug: string) {
// Реальна реалізація: return await db.post.findUnique({ where: { slug } });
if (slug === 'not-found') return null;
return {
slug,
title: 'Архітектура React Server Components',
excerpt: 'Поглиблений посібник із серіалізації та стрімінгу через протокол React Flight.',
publishedAt: '2026-03-01T10:00:00.000Z',
updatedAt: '2026-03-05T14:30:00.000Z',
};
}
export async function generateMetadata(
{ params }: BlogPostPageProps,
parent: ResolvingMetadata
): Promise<Metadata> {
// Обов'язковий await для params у Next.js 15+
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) {
return {
title: 'Статтю не знайдено',
};
}
// За потреби успадковуємо або доповнюємо зображення з батьківських layouts
const parentImages = (await parent).openGraph?.images || [];
return {
title: post.title, // Перетворюється на: "Архітектура React Server Components | Fullstack Agentic Lab"
description: post.excerpt,
alternates: {
canonical: `/blog/${post.slug}`, // Розв'язується через metadataBase
},
openGraph: {
type: 'article',
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
title: post.title,
description: post.excerpt,
images: [`/blog/${post.slug}/og-image.png`, ...parentImages],
},
};
}
export default async function BlogPostPage({ params }: BlogPostPageProps) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) notFound();
return (
<article className="max-w-3xl mx-auto py-10 space-y-6">
<h1 className="text-4xl font-extrabold text-text-primary tracking-tight">{post.title}</h1>
<p className="text-lg text-text-muted leading-relaxed">{post.excerpt}</p>
</article>
);
}В app/layout.tsx розробник налаштовує openGraph: { images: ['/og.png'] }, проте забуває оголосити metadataBase. Що відбудеться, коли посилання буде надіслано в Telegram, Slack або Twitter/X?
sitemap.xml та robots.txt#Next.js підтримує нативну генерацію службових SEO-файлів мовою TypeScript завдяки простору імен MetadataRoute.
import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://agenticlab.dev';
// Динамічний запит до бази даних для всіх опублікованих публікацій
// const posts = await db.post.findMany({ select: { slug: true, updatedAt: true } });
const posts = [
{ slug: 'rsc-deep-dive', updatedAt: new Date('2026-03-01') },
{ slug: 'form-validation', updatedAt: new Date('2026-03-04') },
];
const postUrls: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'weekly',
priority: 0.8,
}));
return [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1.0,
},
{
url: `${baseUrl}/courses`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.9,
},
...postUrls,
];
}import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://agenticlab.dev';
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/admin/', '/api/', '/dashboard/private/'],
},
{
userAgent: 'Googlebot',
allow: '/',
disallow: ['/admin/', '/api/'],
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}Структуровані дані Schema.org можна розміщувати безпосередньо всередині Server Components за допомогою тегу <script type="application/ld+json">.
import React from 'react';
interface CourseJsonLdProps {
title: string;
description: string;
courseUrl: string;
}
export function CourseStructuredData({ title, description, courseUrl }: CourseJsonLdProps) {
const schema = {
'@context': 'https://schema.org',
'@type': 'Course',
name: title,
description: description,
provider: {
'@type': 'Organization',
name: 'Fullstack Agentic Lab',
sameAs: 'https://agenticlab.dev',
},
hasCourseInstance: {
'@type': 'CourseInstance',
courseMode: 'online',
courseWorkload: 'PT25H',
},
url: courseUrl,
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}title.absolute#Якщо батьківський layout визначає шаблон template: '%s | Fullstack Agentic Lab', дочірня сторінка з title: 'Головна' автоматично рендериться як 'Головна | Fullstack Agentic Lab'. Якщо сторінці потрібен автономний заголовок без суфікса бренду, використовуйте властивість absolute:
export const metadata: Metadata = {
title: {
absolute: 'Fullstack Agentic Lab — Провідна інженерна платформа для Senior розробників',
},
};Для панелей адміністрування та автентифікованих кабінетів користувача (/dashboard, /admin) явно забороняйте пошуковим ботам індексувати закриті сторінки або переходити за внутрішніми посиланнями:
// app/admin/layout.tsx
export const metadata: Metadata = {
title: 'Консоль адміністратора',
robots: {
index: false,
follow: false,
nocache: true,
},
};Функція generateMetadata та компонент Page викликають ідентичну асинхронну функцію: await getProduct(params.id). Як уникнути подвійних запитів до бази даних?
Звернення до параметрів маршруту безпосередньо через const { slug } = params без використання await у Next.js 15+ викликає попередження компілятора та призводить до збоїв у Next.js 16.
Рішення: Завжди типізуйте параметри як Promise: params: Promise<{ slug: string }> та явно викликайте const { slug } = await params; як у generateMetadata, так і в компоненті Page.
Якщо в app/layout.tsx пропущено metadataBase, а відносні шляхи до зображень оголошено (openGraph: { images: ['/og.png'] }), Next.js не зможе сформувати абсолютний URL. Краулери соціальних мереж (Slack, Telegram, Twitter/X) не зможуть відобразити картку попереднього перегляду.
Рішення: Завжди вказуйте metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'https://agenticlab.dev') у кореневому app/layout.tsx.
Якщо пропустити директиви виключення пошукових роботів на внутрішніх панелях керування (/dashboard, /admin), пошукові системи можуть проіндексувати закриті маршрути, відображаючи внутрішні посилання в публічній видачі.
Рішення: У файлах layout для адміністративних розділів обов'язково експортуйте: export const metadata: Metadata = { robots: { index: false, follow: false } }.
Якщо кореневий layout містить title: { template: '%s | Fullstack Agentic Lab' }, призначення простого title: 'Головна' на лендингу перетвориться на 'Головна | Fullstack Agentic Lab'.
Рішення: Використовуйте модифікатор absolute для незалежних заголовків: title: { absolute: 'Власний автономний заголовок' }.
Спроба експортувати metadata або generateMetadata з файлу з директивою 'use client' викликає помилку компіляції. Обробка метаданих відбувається виключно на стороні сервера під час збірки або обробки запиту.
Рішення: Залишайте page.tsx як Server Component, делегуючи інтерактивну логіку дочірнім клієнтським компонентам.
Виклик однакових запитів до бази даних у generateMetadata та Page може викликати побоювання щодо зайвого навантаження на бекенд.
Рішення: Для нативних викликів fetch() Next.js здійснює автоматичну мемоїзацію запитів. Для прямих запитів через ORM (Prisma/Drizzle) огортайте функції доступу до даних за допомогою утиліти cache() від React.