En la arquitectura clásica de Single Page Applications (SPA) del lado del cliente basada en React, la optimización para motores de búsqueda (SEO) representaba un desafío fundamental: los rastreadores web (Googlebot, Bingbot) recibían un esqueleto HTML vacío <div id="root"></div> y se veían obligados a ejecutar una costosa fase de renderizado de JavaScript (Web Rendering Service) para extraer el contenido textual y las metaetiquetas. Este proceso solía causar retrasos de indexación de semanas y posiciones volátiles en los resultados de búsqueda.
Next.js App Router resuelve este problema gracias a los React Server Components (RSC) y a la Metadata API integrada. Los motores de búsqueda y los rastreadores de redes sociales reciben documentos HTML válidos y completamente renderizados con etiquetas <title>, <meta>, tarjetas de vista previa OpenGraph (OG), directivas de Canonical URL y microdatos semánticos JSON-LD.
La Metadata API proporciona una interfaz fuertemente tipada y componible que gestiona automáticamente la cascada jerárquica y la fusión de metadatos entre layouts y páginas anidadas sin colisiones en la etiqueta <head>.
La Metadata API se ejecuta exclusivamente en el servidor antes de transmitir el fragmento inicial de HTML al cliente. Los módulos de ruta (layout.tsx o page.tsx) exportan un objeto de configuración estático metadata o una función generadora asíncrona generateMetadata.
Los metadatos se transmiten en cascada hacia abajo en el árbol de rutas:
app/layout.tsx raíz (como favicon por defecto, locale y plantilla de título) se aplican automáticamente a todas las páginas secundarias de la aplicación.page.tsx define una propiedad ya declarada en un layout padre (por ejemplo, description), el valor secundario reemplaza al del padre.openGraph.images) pueden combinarse con las definiciones del padre utilizando el helper parent: ResolvingMetadata dentro de generateMetadata./og.png) y URLs canónicas se resuelven automáticamente en URLs absolutas tomando como referencia el metadataBase configurado en el layout raíz.Un rastreador o navegador solicita una URL (por ejemplo, /blog/rsc-deep-dive). Next.js resuelve la jerarquía de layouts hasta el page.tsx correspondiente.
Se procesan las exportaciones de metadatos estáticos de app/layout.tsx y layouts intermedios, estableciendo valores predeterminados y title.template.
La función generateMetadata se ejecuta concurrentemente, obteniendo datos del DAL/ORM con deduplicación y memoización automática.
Next.js fusiona metadatos de padres e hijos. Las rutas relativas de imágenes y enlaces canónicos se transforman en URLs absolutas.
El bloque <head> generado se transmite al cliente en el primer flujo de bytes antes de que finalice el streaming del cuerpo en RSC.
metadataBase#El archivo raíz app/layout.tsx define el dominio base de la aplicación, las plantillas de título por defecto y las reglas globales de indexación para los rastreadores:
import type { Metadata } from 'next';
export const metadata: Metadata = {
// Obligatorio: dominio base para resolver rutas relativas en URLs absolutas
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'https://agenticlab.dev'),
title: {
template: '%s | Fullstack Agentic Lab',
default: 'Fullstack Agentic Lab — Plataforma Educativa para Ingenieros',
},
description: 'Cursos avanzados de Next.js, React, arquitectura de bases de datos y flujos agénticos.',
keywords: ['Next.js 15', 'React 19', 'TypeScript', 'App Router', 'Fullstack'],
authors: [{ name: 'Equipo de Fullstack Agentic Lab', url: 'https://agenticlab.dev' }],
creator: 'Fullstack Agentic Lab',
openGraph: {
type: 'website',
locale: 'es_ES',
url: 'https://agenticlab.dev',
siteName: 'Fullstack Agentic Lab',
title: 'Fullstack Agentic Lab — Plataforma Educativa para Ingenieros',
description: 'Plan de estudios de alto rendimiento para ingenieros senior.',
images: [
{
url: '/og-default.png', // Se resuelve como https://agenticlab.dev/og-default.png
width: 1200,
height: 630,
alt: 'Vista previa de la plataforma Fullstack Agentic Lab',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Fullstack Agentic Lab',
description: 'Arquitectura avanzada con Next.js, React y agentes de IA',
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="es">
<body>{children}</body>
</html>
);
}generateMetadata)#En Next.js 15 y 16, los parámetros de ruta (params) y los parámetros de búsqueda (searchParams) son Promises asíncronas.
import type { Metadata, ResolvingMetadata } from 'next';
import { notFound } from 'next/navigation';
interface BlogPostPageProps {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
// Consulta de la capa de acceso a datos (DAL) con deduplicación
async function getPostBySlug(slug: string) {
// Implementación real: return await db.post.findUnique({ where: { slug } });
if (slug === 'not-found') return null;
return {
slug,
title: 'Arquitectura de React Server Components',
excerpt: 'Guía exhaustiva sobre serialización y streaming con el protocolo 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> {
// await obligatorio en params en Next.js 15+
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) {
return {
title: 'Artículo no encontrado',
};
}
// Opcionalmente hereda o extiende imágenes de layouts padre
const parentImages = (await parent).openGraph?.images || [];
return {
title: post.title, // Se resuelve como: "Arquitectura de React Server Components | Fullstack Agentic Lab"
description: post.excerpt,
alternates: {
canonical: `/blog/${post.slug}`, // Se resuelve mediante 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>
);
}En app/layout.tsx, un desarrollador configura openGraph: { images: ['/og.png'] }, pero olvida declarar metadataBase. ¿Qué ocurre cuando se comparten enlaces en Telegram, Slack o Twitter/X?
sitemap.xml y robots.txt#Next.js permite generar archivos de servicio SEO de manera nativa en TypeScript mediante el espacio de nombres 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';
// Consulta dinámica a la base de datos para todos los artículos publicados
// 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: 'Googlebot',
allow: '/',
disallow: ['/admin/', '/api/'],
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}Los datos estructurados de Schema.org pueden incluirse directamente dentro de Server Components usando una etiqueta <script type="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#Cuando un layout padre define template: '%s | Fullstack Agentic Lab', una página secundaria con title: 'Inicio' se renderiza automáticamente como 'Inicio | Fullstack Agentic Lab'. Para asignar un título autónomo sin el sufijo de la marca, utiliza la propiedad absolute:
export const metadata: Metadata = {
title: {
absolute: 'Fullstack Agentic Lab — Plataforma de Ingeniería Avanzada para Desarrolladores Senior',
},
};En paneles de administración y áreas autenticadas de usuario (/dashboard, /admin), prohíbe explícitamente que los motores de búsqueda indexen páginas privadas o sigan enlaces internos:
// app/admin/layout.tsx
export const metadata: Metadata = {
title: 'Consola de Administración',
robots: {
index: false,
follow: false,
nocache: true,
},
};Tanto la función generateMetadata como el componente Page ejecutan la misma función asíncrona: await getProduct(params.id). ¿Cómo evitas peticiones duplicadas a la base de datos?
Acceder a los parámetros de ruta directamente mediante const { slug } = params sin await en Next.js 15+ genera advertencias del compilador y causa errores fatales en Next.js 16.
Solución: Tipa siempre los parámetros como Promise: params: Promise<{ slug: string }> y resuélvelos explícitamente mediante const { slug } = await params; tanto en generateMetadata como en el componente Page.
Omitir metadataBase en app/layout.tsx mientras se definen rutas relativas de imagen (openGraph: { images: ['/og.png'] }) impide que Next.js calcule URLs absolutas. Los rastreadores de redes sociales (Slack, Telegram, Twitter/X) no pueden mostrar las tarjetas de previsualización.
Solución: Define siempre metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'https://agenticlab.dev') en el app/layout.tsx raíz.
Olvidar declarar directivas de exclusión de robots en paneles administrativos internos (/dashboard, /admin) permite que los rastreadores indexen rutas privadas, exponiendo URLs internas en las búsquedas públicas.
Solución: En los archivos de layout administrativo, exporta: export const metadata: Metadata = { robots: { index: false, follow: false } }.
Si el layout raíz define title: { template: '%s | Fullstack Agentic Lab' }, asignar un simple title: 'Inicio' en la landing page genera 'Inicio | Fullstack Agentic Lab'.
Solución: Usa el modificador absolute para títulos independientes: title: { absolute: 'Título Personalizado Independiente' }.
Intentar exportar metadata o generateMetadata desde un archivo marcado con 'use client' genera un error de compilación. La evaluación de metadatos es una tarea exclusiva del servidor durante la compilación y la resolución de peticiones.
Solución: Mantén page.tsx como Server Component, delegando la lógica interactiva a componentes cliente hijos.
Invocar consultas a la base de datos tanto en generateMetadata como en Page puede generar preocupaciones sobre una carga duplicada en el backend.
Solución: En el caso de peticiones nativas con fetch(), Next.js deduplica las solicitudes automáticamente. Para consultas directas a ORMs (Prisma/Drizzle), envuelve las funciones de acceso a datos con la utilidad cache() de React.