В классической клиентской архитектуре SPA (Single Page Applications) на базе React поисковая оптимизация (SEO) представляла фундаментальную проблему: поисковые краулеры (Googlebot, YandexBot) получали пустой 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 (например, фавиконки, локаль, базовая маска заголовка), автоматически применяются ко всем дочерним страницам приложения.page.tsx определяет ключ, уже существующий в родительском layout (например, description), дочернее значение замещает родительское.openGraph.images) объединяются с родительскими значениями, если в generateMetadata используется хелпер parent: ResolvingMetadata./og.png) и канонические адреса автоматически приводятся к абсолютным URL на базе значения, заданного в корневом layout.Браузер или поисковый краулер запрашивает URL (например, /courses/nextjs-mastery). Next.js идентифицирует дерево layouts и целевой page.tsx.
Считываются статические экспорты metadata из app/layout.tsx и родительских сегментов маршрута. Формируются значения по умолчанию и title.template.
Параллельно запускается функция generateMetadata. Выполняется дедуплицированный запрос к ORM/API для получения динамических данных страницы.
Next.js объединяет родительские и дочерние свойства. Относительные ссылки на OG-картинки и канонические пути автоматически превращаются в абсолютные URL через metadataBase.
Сформированный блок <head> отправляется клиенту первым чанком до завершения потокового рендеринга тела страницы (RSC Streaming).
metadataBase#Корневой файл app/layout.tsx задает базовый домен, шаблон заголовка и глобальные параметры индексации.
import type { Metadata } from 'next';
export const metadata: Metadata = {
// Обязательно: базовый домен для корректного разрешения всех относительных ссылок
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, архитектуре баз данных и AI-разработке.',
keywords: ['Next.js 16', 'React 19', 'TypeScript', 'App Router', 'Fullstack'],
authors: [{ name: 'Fullstack Agentic Lab Team', url: 'https://agenticlab.dev' }],
creator: 'Fullstack Agentic Lab',
openGraph: {
type: 'website',
locale: 'ru_RU',
url: 'https://agenticlab.dev',
siteName: 'Fullstack Agentic Lab',
title: 'Fullstack Agentic Lab - Образовательная платформа',
description: 'Глубокие курсы для Middle и Senior инженеров.',
images: [
{
url: '/og-default.png', // Преобразуется в https://agenticlab.dev/og-default.png
width: 1200,
height: 630,
alt: 'Fullstack Agentic Lab Platform Preview',
},
],
},
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="ru">
<body>{children}</body>
</html>
);
}generateMetadata)#В Next.js 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 / ORM с дедупликацией
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> {
// В Next.js 16 обязательно использование await params
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) {
return {
title: 'Материал не найден',
};
}
// Получаем изображения из родительского layout при необходимости
const parentImages = (await parent).openGraph?.images || [];
return {
title: post.title, // С учетом template сформирует: "Архитектура 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 или соцсети?
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: 'YandexBot',
allow: '/',
disallow: ['/admin/', '/api/'],
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}Разметка по стандартам Schema.org внедряется непосредственно в серверный компонент в виде скрипта с типом 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 Fullstack & AI разработчиков',
},
};Для внутренних страниц личного кабинета или административных панелей (/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 16 приводит к падению серверного рендера с фатальной ошибкой компилятора.
Решение: Всегда типизируйте параметры как промис params: Promise<{ slug: string }> и явно извлекайте их через const { slug } = await params; как в функции generateMetadata, так и в самом серверном компоненте Page.
Если в app/layout.tsx не задан metadataBase, а в дочерних маршрутах используются относительные пути к картинкам (например, openGraph: { images: ['/og.png'] }), Next.js не сможет преобразовать их в абсолютные URL. Поисковые краулеры и мессенджеры (Telegram, WhatsApp, Twitter) не смогут отобразить превью страницы.
Решение: Всегда объявляйте metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'https://agenticlab.dev') в корневом app/layout.tsx.
Если в админ-панели или личных кабинетах (/dashboard, /admin, /profile) не переопределить метаданные роботов, поисковые боты проиндексируют приватные URL и их сниппеты попадут в публичную поисковую выдачу.
Решение: В 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' приведет к ошибке компиляции Next.js. Метаданные вычисляются строго на сервере до рендеринга страницы.
Решение: Оставляйте файл page.tsx серверным, а интерактивную клиентскую логику выносите во вложенные компоненты.
Вызов одного и того же запроса к БД в generateMetadata и в компоненте страницы создает опасение двойной нагрузки на базу данных.
Решение: Для запросов через нативный fetch() Next.js автоматически выполняет Request Memoization. Для прямых запросов через ORM (Prisma/Drizzle) оберните функцию доступа к данным в утилиту cache() из пакета 'react'.