В динамических веб-приложениях (электронная коммерция, новостные медиа, контентные хабы) содержимое страницы определяется параметрами маршрута ([slug], [id]) и состоянием базы данных. Метаданные таких страниц не могут быть статически зафиксированы на этапе компиляции приложения: заголовок статьи, цена товара, превью OpenGraph и канонический URL должны формироваться на сервере во время обработки запроса или инкрементальной регенерации.
В Next.js параметры маршрута params и строка запроса searchParams переведены в асинхронную модель и передаются в компоненты и функции сегментов как промисы (Promise<Params>). Это изменение обусловлено переходом на частичный предварительный рендеринг (PPR), при котором сервер начинает отдавать статическую часть разметки до полного разрешения параметров динамического сегмента.
Для вычисления метаданных на лету фреймворк предоставляет асинхронный экспортируемый метод generateMetadata. Он выполняется исключительно на сервере и напрямую влияет на формирование начального HTML-шелла страницы. Чтобы избежать деградации TTFB из-за сетевых водопадов (Network Waterfalls), Next.js объединяет исполнение generateMetadata и серверного компонента страницы в единый параллельный контекст исполнения.
Когда клиент запрашивает динамический маршрут (например, /products/mechanical-keyboard), Next.js параллельно инициирует два асинхронных процесса: вычисление generateMetadata() и рендеринг Page().
Если оба процесса обращаются к одному источнику данных (база данных через ORM Prisma/Drizzle или сторонний REST API), возникает риск дублирования I/O-операций. Next.js решает эту проблему на двух уровнях:
fetch() через Request Memoization.React.cache().Входящий HTTP-запрос (/products/keyboard)
│
┌───────────────────────────────┴───────────────────────────────┐
│ │
▼ ▼
[ generateMetadata(props, parent) ] [ ProductPage(props) ]
│ │
├──────── await params ──────────┐ ├──────── await params ──────────┐
│ │ │ │
▼ ▼ ▼ ▼
slug = "keyboard" slug = "keyboard" slug = "keyboard" slug = "keyboard"
│ │
▼ ▼
getProduct("keyboard") ─────────────────────────────────────────► getProduct("keyboard")
│ │
▼ │ (Возврат из кэша)
[ React.cache() Store ] ◄────────────────────────────────────────────────┘ [HIT: 0ms дополнительно]
│
[ Запрос к Postgres/API ] (Выполняется ровно 1 раз)
│
▼
[ Данные получены ]
│
├───────────────────────────────────────────────┐
▼ ▼
await parent (ResolvingMetadata) Генерация JSX-разметки
│ │
▼ ▼
Мерж родительского OG RSC Flight Payload Stream
│ │
▼ │
Формирование тегов <head> │
│ │
└───────────────────────┬───────────────────────┘
▼
[ Итоговый HTML-ответ (200 OK) ]Next.js сопоставляет URL с шаблоном [slug] и инициализирует контекст выполнения с промисами params и searchParams.
Диспетчер RSC запускает генерацию метаданных и рендер страницы одновременно в едином пуле воркеров.
Первый дошедший запрос обращается к источнику данных. Второй вызов мгновенно подхватывает результат из замыкания кэша.
Вызов await parent возвращает финальные метаданные вышележащих макетов для слияния списков OpenGraph-картинок.
Скомпилированные теги заголовков отправляются в первом чанке стримингового ответа HTTP-клиенту.
Почему при обращении к базе данных внутри generateMetadata и серверного компонента Page в Next.js 16 не происходит двойного обращения к СУБД при использовании React.cache()?
ResolvingMetadata.searchParams): Анализ поисковых параметров для динамической установки директивы <meta name="robots" content="noindex, follow">, исключающей появление сотен мусорных комбинаций фасетных фильтров в индексе.notFound() внутри generateMetadata, гарантирующий отдачу строгого HTTP-статуса 404 и отображение изолированного интерфейса not-found.tsx.Ниже представлена эталонная архитектура динамической страницы каталога в Next.js 15 с использованием TypeScript, React.cache(), работы с промисами params и searchParams, а также расширением родительских метаданных.
lib/data/products.ts)#import { cache } from 'react';
export interface Product {
id: string;
slug: string;
title: string;
description: string;
price: number;
currency: string;
inStock: boolean;
images: string[];
updatedAt: string;
}
// React.cache мемоизирует вызов функции на время жизненного цикла текущего HTTP-запроса.
// Это предотвращает двойной запрос к БД из generateMetadata и серверного компонента Page.
export const getProductBySlug = cache(async (slug: string): Promise<Product | null> => {
// Эмуляция обращения к базе данных через ORM (Drizzle/Prisma)
try {
const res = await fetch(`https://api.agenticlab.dev/v1/products/${encodeURIComponent(slug)}`, {
// Использование тэгов кэша Next.js для On-Demand ревалидации
next: { tags: [`product:${slug}`], revalidate: 3600 },
});
if (!res.ok) {
if (res.status === 404) return null;
throw new Error(`Failed to fetch product: ${res.statusText}`);
}
const product: Product = await res.json();
return product;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Database connection error';
console.error(`[getProductBySlug] Error fetching slug "${slug}":`, message);
return null;
}
});app/products/[slug]/page.tsx)#import type { Metadata, ResolvingMetadata } from 'next';
import { notFound } from 'next/navigation';
import { getProductBySlug, type Product } from '@/lib/data/products';
// Строгая типизация пропсов страницы в Next.js 15:
// params и searchParams обязаны быть промисами
interface ProductPageProps {
params: Promise<{
slug: string;
}>;
searchParams: Promise<{
[key: string]: string | string[] | undefined;
}>;
}
// 1. Асинхронный генератор динамических метаданных
export async function generateMetadata(
{ params, searchParams }: ProductPageProps,
parent: ResolvingMetadata
): Promise<Metadata> {
// Обязательный await параметров в Next.js 15
const { slug } = await params;
const currentSearchParams = await searchParams;
// Извлечение данных через дедуплицированную функцию
const product: Product | null = await getProductBySlug(slug);
// Если ресурс не найден в БД, вызываем notFound()
// Это предотвращает дальнейшее выполнение и переключает рендер на not-found.tsx
if (!product) {
notFound();
}
// Получаем разрешенные метаданные родительских макетов (layout.tsx)
const parentMetadata = await parent;
const previousImages = parentMetadata.openGraph?.images || [];
// Анализ searchParams: если применены служебные фильтры или промокоды, закрываем от индекса
const hasTrackingParams = Boolean(currentSearchParams['ref'] || currentSearchParams['discount']);
const formattedPrice = new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: product.currency,
}).format(product.price);
return {
title: `${product.title} — ${formattedPrice}`,
description: product.description.slice(0, 160),
alternates: {
canonical: `/products/${product.slug}`,
},
robots: {
index: !hasTrackingParams,
follow: true,
googleBot: {
index: !hasTrackingParams,
follow: true,
},
},
openGraph: {
title: `${product.title} | Fullstack Agentic Lab`,
description: `Купить ${product.title} за ${formattedPrice}. В наличии: ${product.inStock ? 'Да' : 'Нет'}.`,
url: `/products/${product.slug}`,
type: 'website',
// Расширяем массив родительских картинок уникальными фотографиями товара
images: [
...product.images.map((img) => ({
url: img,
width: 1200,
height: 630,
alt: product.title,
})),
...previousImages,
],
},
other: {
'product:price:amount': product.price.toString(),
'product:price:currency': product.currency,
'product:availability': product.inStock ? 'in stock' : 'out of stock',
},
};
}
// 2. Серверный компонент страницы
export default async function ProductPage({ params }: ProductPageProps) {
const { slug } = await params;
// Повторный вызов getProductBySlug мгновенно вернет закэшированный промис из React.cache
const product: Product | null = await getProductBySlug(slug);
if (!product) {
notFound();
}
return (
<article className="max-w-5xl mx-auto px-4 py-8">
<header className="mb-6">
<h1 className="text-3xl font-extrabold tracking-tight">{product.title}</h1>
<p className="text-2xl font-semibold text-accent-cyan mt-2">
{product.price} {product.currency}
</p>
</header>
<div className="prose prose-invert max-w-none">
<p>{product.description}</p>
</div>
</article>
);
}Какая критическая проблема возникает, если внутри generateMetadata не вызвать notFound() при отсутствии запрашиваемой сущности в базе данных?
В версиях Next.js до 14 параметры передавались синхронным объектом: const { slug } = props.params. Начиная с Next.js 15 и 16, прямое обращение без await вызовет ошибку во время выполнения:
Error: Route "/products/[slug]" used "params.slug". "params" should be awaited before using its properties.
Всегда объявляйте params как Promise<{ slug: string }> и явно разрешайте их: const { slug } = await params;.
Автоматическая дедупликация Next.js по умолчанию действует только на глобальный fetch(). Если внутри generateMetadata и внутри серверного компонента страницы выполняются прямые запросы через ORM (prisma.post.findUnique(...) или db.select().from(...)), сервер выполнит два одинаковых тяжелых сетевых запроса к СУБД на каждый просмотр страницы. Обязательно оборачивайте функции доступа к данным в cache() из пакета 'react'.
Если в серверном компоненте страницы проверка if (!data) notFound(); выполнена, а в функции generateMetadata этот сценарий проигнорирован (или возвращены пустые заглушки), краулер поисковой системы получит статус 404 для HTML-тела, но метатеги страницы вернут некорректный заголовок или код ответа 500 из-за падения на data.title. Вызов notFound() внутри generateMetadata обязателен — он прерывает формирование метаданных и согласованно делегирует обработку системному компоненту app/not-found.tsx.
Рендеринг страницы не начнется до тех пор, пока промис generateMetadata не будет разрешен. Выполнение медленных сторонних запросов (например, сбор внешних аналитических рейтингов или обращение к перегруженным микросервисам) внутри generateMetadata линейно увеличивает время ответа сервера (TTFB). Ограничивайте время ожидания (AbortController с таймаутом в 500–800 мс) и используйте кэширующие слои (Redis, Data Cache) для динамических SEO-данных.