En las aplicaciones web dinámicas (comercio electrónico, medios de comunicación, portales de contenidos), el contenido de cada página viene determinado por parámetros de ruta ([slug], [id]) y el estado de la base de datos. Los metadatos de estos entornos no pueden quedar fijados de manera estática durante la compilación: el título del artículo, el precio del producto, la tarjeta OpenGraph y el enlace canónico deben generarse en el servidor durante la tramitación de la solicitud o en su regeneración estática incremental.
En Next.js 15 se introdujo un cambio arquitectónico fundamental: los parámetros de ruta params y las cadenas de consulta searchParams pasaron a ser asíncronos y se entregan a componentes y funciones de segmento como promesas (Promise<Params>). Esta transformación atiende a la adopción del renderizado previo parcial (PPR), donde el servidor comienza a emitir la estructura estática antes de resolver por completo los parámetros dinámicos de la ruta.
Para computar metadatos en tiempo de ejecución, el framework suministra la función asíncrona exportable generateMetadata. Se ejecuta exclusivamente en el servidor e influye de forma directa en el ensamblaje del shell HTML inicial. Para evitar deterioros de TTFB provocados por cascadas de red (Network Waterfalls), Next.js 15 sincroniza la ejecución de generateMetadata y la del componente servidor de página en un contexto paralelo unificado.
Cuando un cliente solicita una ruta dinámica (por ejemplo, /products/mechanical-keyboard), Next.js pone en marcha dos procesos asíncronos en paralelo: la evaluación de generateMetadata() y el renderizado de ProductPage().
Si ambos procesos acceden al mismo origen de datos (base de datos vía ORM Prisma/Drizzle o API REST externa), surge el peligro de duplicar operaciones de I/O. Next.js neutraliza esta eventualidad en dos frentes:
fetch() nativas mediante Request Memoization.React.cache().Petición HTTP Entrante (/products/keyboard)
│
┌───────────────────────────────┴───────────────────────────────┐
│ │
▼ ▼
[ generateMetadata(props, parent) ] [ ProductPage(props) ]
│ │
├──────── await params ──────────┐ ├──────── await params ──────────┐
│ │ │ │
▼ ▼ ▼ ▼
slug = "keyboard" slug = "keyboard" slug = "keyboard" slug = "keyboard"
│ │
▼ ▼
getProduct("keyboard") ─────────────────────────────────────────► getProduct("keyboard")
│ │
▼ │ (Retorno desde caché)
[ React.cache() Store ] ◄────────────────────────────────────────────────┘ [HIT: 0ms latencia extra]
│
[ Consulta a Postgres/API ] (Se ejecuta exactamente 1 vez)
│
▼
[ Datos Resueltos ]
│
├───────────────────────────────────────────────┐
▼ ▼
await parent (ResolvingMetadata) Generación de JSX
│ │
▼ ▼
Fusión de Imágenes OG Padre RSC Flight Payload Stream
│ │
▼ │
Montaje de etiquetas <head> │
│ │
└───────────────────────┬───────────────────────┘
▼
[ Respuesta HTML Streamed (200 OK) ]ResolvingMetadata.searchParams): Detección de parámetros de consulta para aplicar condicionalmente <meta name="robots" content="noindex, follow">, impidiendo la indexación masiva de combinaciones irrelevantes.notFound() dentro de generateMetadata, garantizando la emisión estricta del código HTTP 404 y la presentación de la vista not-found.tsx.A continuación se expone la implementación de referencia para rutas dinámicas en Next.js 15 empleando TypeScript, React.cache(), promesas en params y searchParams, y la extensión de metadatos heredados.
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 memoiza la ejecución de la función durante la vida de la petición HTTP actual.
// Esto impide llamadas duplicadas a la base de datos entre generateMetadata y el componente Page.
export const getProductBySlug = cache(async (slug: string): Promise<Product | null> => {
try {
const res = await fetch(`https://api.agenticlab.dev/v1/products/${encodeURIComponent(slug)}`, {
// Tags de caché de Next.js para revalidación bajo demanda
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';
// Tipado riguroso en Next.js 15:
// params y searchParams deben declararse como Promises
interface ProductPageProps {
params: Promise<{
slug: string;
}>;
searchParams: Promise<{
[key: string]: string | string[] | undefined;
}>;
}
// 1. Generador asíncrono de metadatos dinámicos
export async function generateMetadata(
{ params, searchParams }: ProductPageProps,
parent: ResolvingMetadata
): Promise<Metadata> {
// Resolución obligatoria con await en Next.js 15
const { slug } = await params;
const currentSearchParams = await searchParams;
// Consulta a través de la función memoizada
const product: Product | null = await getProductBySlug(slug);
// Si el registro no existe, interrumpir y derivar a 404
if (!product) {
notFound();
}
// Esperar los metadatos resueltos de los layouts ascendentes
const parentMetadata = await parent;
const previousImages = parentMetadata.openGraph?.images || [];
// Analizar searchParams: bloquear indexación ante filtros o campañas de tracking
const hasTrackingParams = Boolean(currentSearchParams['ref'] || currentSearchParams['discount']);
const formattedPrice = new Intl.NumberFormat('es-ES', {
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: `Comprar ${product.title} por ${formattedPrice}. Disponibilidad: ${product.inStock ? 'En stock' : 'Agotado'}.`,
url: `/products/${product.slug}`,
type: 'website',
// Anexar las imágenes del producto al conjunto del layout padre
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. Componente de Servidor de la Página
export default async function ProductPage({ params }: ProductPageProps) {
const { slug } = await params;
// La segunda invocación a getProductBySlug resuelve al instante desde 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>
);
}En versiones previas de Next.js, los parámetros se leían de forma síncrona: const { slug } = props.params. A partir de Next.js 15, omitir el await provocará una advertencia o un fallo en tiempo de ejecución:
Error: Route "/products/[slug]" used "params.slug". "params" should be awaited before using its properties.
Declare siempre params como Promise<{ slug: string }> y resuélvalo con const { slug } = await params;.
La deduplicación nativa de Next.js actúa únicamente sobre fetch() global. Si dentro de generateMetadata y dentro de ProductPage se efectúan consultas con un ORM (prisma.product.findUnique() o db.query()), el servidor ejecutará dos consultas redundantes a la base de datos por cada visualización de página. Envuelva invariablemente sus funciones con cache() de 'react'.
Si el componente de página valida if (!data) notFound(); pero generateMetadata ignora esta comprobación devolviendo valores vacíos, el crawler recibirá un cuerpo 404 con cabeceras 200 OK, o la app fallará con error 500 al intentar leer data.title. Invocar notFound() dentro de generateMetadata es imprescindible: detiene el pipeline de metadatos y delega la respuesta a app/not-found.tsx.
El streaming de la página no puede iniciar hasta resolver la promesa de generateMetadata. Consultar APIs externas pesadas (como recopiladores de reseñas lentos) dentro de generateMetadata degrada directamente el TTFB. Establezca tiempos límite de espera (AbortController entre 500 y 800 ms) y emplee capas de caché (Redis, Data Cache) para los datos SEO.
Next.js asocia la URL con el segmento [slug] y suministra params y searchParams como promesas pendientes.
El despachador de RSC ejecuta la generación de metadatos y la renderización de la página en paralelo.
La primera llamada consulta la base de datos. La segunda llamada consume el resultado en memoria sin coste adicional.
Al resolver await parent, se recuperan los metadatos de los layouts para combinar arrays de imágenes OpenGraph.
Las etiquetas de cabecera compiladas se envían en el primer fragmento de la respuesta SSR al cliente.