У динамічних веб-додатках (електронна комерція, новинні медіа, контентні хаби) вміст сторінки визначається параметрами маршруту ([slug], [id]) та станом бази даних. Метадані таких сторінок не можуть бути статично зафіксовані на етапі компіляції додатка: заголовок статті, ціна товару, прев'ю OpenGraph та канонічний URL повинні формуватися на сервері під час обробки запиту або інкрементальної регенерації.
У Next.js 15 відбувся критичний архітектурний зсув: параметри маршруту params та рядок запиту searchParams переведені в асинхронну модель і передаються в компоненти та функції сегментів як проміси (Promise<Params>). Ця зміна обумовлена переходом на частковий попередній рендеринг (PPR), при якому сервер починає віддавати статичну частину розмітки до повного вирішення параметрів динамічного сегмента.
Для обчислення метаданих на льоту фреймворк надає асинхронний експортований метод generateMetadata. Він виконується виключно на сервері і безпосередньо впливає на формування початкового HTML-шелу сторінки. Щоб уникнути деградації TTFB через мережеві водоспади (Network Waterfalls), Next.js 15 об'єднує виконання generateMetadata та серверного компонента сторінки в єдиний паралельний контекст виконання.
Коли клієнт запитує динамічний маршрут (наприклад, /products/mechanical-keyboard), Next.js паралельно ініціює два асинхронні процеси: обчислення generateMetadata() та рендеринг ProductPage().
Якщо обидва процеси звертаються до одного джерела даних (база даних через 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) ]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> => {
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('uk-UA', {
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>
);
}У версіях Next.js до 14 параметри передавалися синхронним об'єктом: const { slug } = props.params. Починаючи з Next.js 15, пряме звернення без 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.product.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-даних.
Next.js зіставляє URL з шаблоном [slug] та ініціалізує контекст виконання з промісами params і searchParams.
Диспетчер RSC запускає генерацію метаданих та рендер сторінки одночасно в єдиному пулі воркерів.
Перший запит звертається до джерела даних. Другий виклик миттєво підхоплює результат із замикання кешу.
Виклик await parent повертає фінальні метадані вищерозташованих макетів для злиття списків OpenGraph-зображень.
Скомпільовані теги заголовків відправляються в першому чанку стрімінгової відповіді HTTP-клієнту.