In dynamic web applications (e-commerce platforms, news publications, content hubs), document contents are determined by route parameters ([slug], [id]) and database state. Metadata for these views cannot be statically compiled at build time: article headlines, live product pricing, OpenGraph cards, and canonical links must be synthesized on the server during request handling or incremental static regeneration.
Next.js 15 introduced a pivotal architectural evolution: route parameters params and query parameters searchParams transitioned into an asynchronous model, delivered to components and route segment hooks as promises (Promise<Params>). This shift supports Partial Prerendering (PPR), allowing servers to stream initial layout frames before resolving dynamic route segment parameters.
To compute metadata on the fly, the framework provides the asynchronous export generateMetadata. Executed strictly on the server, it directly shapes the initial HTML response shell. To safeguard TTFB against Network Waterfalls, Next.js 15 coordinates the execution of generateMetadata and the page server component within a concurrent execution context.
When a client requests a dynamic route (e.g., /products/mechanical-keyboard), Next.js initiates two asynchronous operations in parallel: evaluating generateMetadata() and rendering ProductPage().
When both pipelines access the same data source (an ORM like Prisma/Drizzle or an external REST API), duplicate I/O operations risk bottlenecking throughput. Next.js neutralizes this problem on two levels:
fetch() calls via Request Memoization.React.cache().Incoming HTTP Request (/products/keyboard)
│
┌───────────────────────────────┴───────────────────────────────┐
│ │
▼ ▼
[ generateMetadata(props, parent) ] [ ProductPage(props) ]
│ │
├──────── await params ──────────┐ ├──────── await params ──────────┐
│ │ │ │
▼ ▼ ▼ ▼
slug = "keyboard" slug = "keyboard" slug = "keyboard" slug = "keyboard"
│ │
▼ ▼
getProduct("keyboard") ─────────────────────────────────────────► getProduct("keyboard")
│ │
▼ │ (Cache return)
[ React.cache() Store ] ◄────────────────────────────────────────────────┘ [HIT: 0ms added latency]
│
[ Query to Postgres/API ] (Executes exactly once)
│
▼
[ Data Resolved ]
│
├───────────────────────────────────────────────┐
▼ ▼
await parent (ResolvingMetadata) Render JSX Layout
│ │
▼ ▼
Merge Parent OG Images RSC Flight Payload Stream
│ │
▼ │
Assemble <head> tags │
│ │
└───────────────────────┬───────────────────────┘
▼
[ Streamed HTML Shell (200 OK) ]ResolvingMetadata.searchParams): Evaluating query parameters to dynamically assign <meta name="robots" content="noindex, follow">, preventing search index pollution across thousands of faceted combinations.notFound() inside generateMetadata to guarantee a true HTTP 404 status code while rendering the isolated not-found.tsx view.Below is the standard production architecture for dynamic catalog routes in Next.js 15, integrating TypeScript, React.cache(), asynchronous params and searchParams, and parent metadata inheritance.
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 memoizes function invocations for the duration of the current HTTP request.
// This prevents duplicate database queries between generateMetadata and the Page component.
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 cache tags for on-demand revalidation
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';
// Strict typing for Next.js 15 page props:
// params and searchParams must be typed as Promises
interface ProductPageProps {
params: Promise<{
slug: string;
}>;
searchParams: Promise<{
[key: string]: string | string[] | undefined;
}>;
}
// 1. Asynchronous dynamic metadata generator
export async function generateMetadata(
{ params, searchParams }: ProductPageProps,
parent: ResolvingMetadata
): Promise<Metadata> {
// Explicitly await route parameters in Next.js 15
const { slug } = await params;
const currentSearchParams = await searchParams;
// Fetch product data via memoized data access function
const product: Product | null = await getProductBySlug(slug);
// If record is absent, invoke notFound() immediately
// This halts execution and routes rendering to not-found.tsx with an HTTP 404
if (!product) {
notFound();
}
// Await resolved metadata from parent layout segments (layout.tsx)
const parentMetadata = await parent;
const previousImages = parentMetadata.openGraph?.images || [];
// Inspect searchParams: set noindex if faceted filters or tracking codes exist
const hasTrackingParams = Boolean(currentSearchParams['ref'] || currentSearchParams['discount']);
const formattedPrice = new Intl.NumberFormat('en-US', {
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: `Purchase ${product.title} for ${formattedPrice}. In Stock: ${product.inStock ? 'Yes' : 'No'}.`,
url: `/products/${product.slug}`,
type: 'website',
// Append product images to the parent layout image array
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. Page Server Component
export default async function ProductPage({ params }: ProductPageProps) {
const { slug } = await params;
// Invoking getProductBySlug resolves instantaneously from 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>
);
}Prior to Next.js 15, route parameters were accessed synchronously: const { slug } = props.params. In Next.js 15, direct synchronous access triggers a runtime error or warning:
Error: Route "/products/[slug]" used "params.slug". "params" should be awaited before using its properties.
Always type params as Promise<{ slug: string }> and resolve via const { slug } = await params;.
Next.js automatic request memoization applies exclusively to native fetch() calls. If generateMetadata and ProductPage execute direct database queries via ORMs (prisma.product.findUnique() or db.query.products.findFirst()), the server triggers two redundant database queries per page view. Always wrap database access functions with cache() from 'react'.
If the page component checks if (!data) notFound(); but generateMetadata neglects this check or outputs empty fallback values, crawlers receive an HTTP 404 body with a 200 OK header, or crash with a 500 runtime exception trying to access data.title. Invoking notFound() inside generateMetadata is mandatory: it cancels metadata resolution and delegates handling to app/not-found.tsx.
Page streaming cannot emit the initial HTML chunk until generateMetadata resolves. Executing sluggish external API calls (e.g., scraping external reviews or slow third-party rating providers) directly inflates server TTFB. Always enforce timeouts (AbortController set to 500–800 ms) and maintain caching layers (Redis, Data Cache) for dynamic SEO data.
Next.js matches the incoming URL to [slug], wrapping params and searchParams into unresolved Promises.
The RSC dispatcher launches metadata generation and page component rendering simultaneously.
The first resolved execution queries the data store. The second consumer resolves instantly from memory.
Evaluating await parent retrieves upstream layout metadata to merge OpenGraph image arrays.
Compiled header tags stream within the very first chunk of server response HTML.