SEO y Metadatos
Receta
Implementa mejores prácticas de SEO en Next.js 15+ App Router utilizando la API de Metadatos, generateMetadata dinámico, mapas de sitio, robots.txt, imágenes Open Graph y datos estructurados JSON-LD.
Ejemplo funcional
Metadatos estáticos
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: {
default: "My App",
template: "%s | My App",
},
description: "A modern web application built with Next.js",
metadataBase: new URL("https://myapp.com"),
openGraph: {
type: "website",
locale: "en_US",
siteName: "My App",
},
twitter: {
card: "summary_large_image",
creator: "@myapp",
},
robots: {
index: true,
follow: true,
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}Metadatos dinámicos con generateMetadata
// app/posts/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation";
type Props = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
const { slug } = await params;
const post = await db.post.findUnique({ where: { slug } });
if (!post) {
return {};
}
const parentMetadata = await parent;
const previousImages = parentMetadata.openGraph?.images ?? [];
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: "article",
publishedTime: post.createdAt.toISOString(),
authors: [post.author.name],
images: [
{
url: `/api/og?title=${encodeURIComponent(post.title)}`,
width: 1200,
height: 630,
alt: post.title,
},
...previousImages,
],
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
},
};
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await db.post.findUnique({ where: { slug } });
if (!post) notFound();
return <article>{post.content}</article>;
}Generación de Sitemap
// app/sitemap.ts
import type { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await db.post.findMany({
select: { slug: true, updatedAt: true },
orderBy: { updatedAt: "desc" },
});
const postEntries = posts.map((post) => ({
url: `https://myapp.com/posts/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: "weekly" as const,
priority: 0.8,
}));
const staticPages = [
{
url: "https://myapp.com",
lastModified: new Date(),
changeFrequency: "daily" as const,
priority: 1.0,
},
{
url: "https://myapp.com/about",
lastModified: new Date(),
changeFrequency: "monthly" as const,
priority: 0.5,
},
];
return [...staticPages, ...postEntries];
}Robots.txt
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/api/", "/admin/", "/dashboard/"],
},
],
sitemap: "https://myapp.com/sitemap.xml",
};
}Datos estructurados JSON-LD
// app/posts/[slug]/page.tsx
import type { WithContext, Article } from "schema-dts";
function JsonLd({ data }: { data: WithContext<Article> }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await db.post.findUnique({ where: { slug } });
if (!post) notFound();
const jsonLd: WithContext<Article> = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.excerpt,
datePublished: post.createdAt.toISOString(),
dateModified: post.updatedAt.toISOString(),
author: {
"@type": "Person",
name: post.author.name,
},
};
return (
<>
<JsonLd data={jsonLd} />
<article>{post.content}</article>
</>
);
}Imagen OG dinámica
// app/api/og/route.tsx
import { ImageResponse } from "next/og";
import { NextRequest } from "next/server";
export const runtime = "edge";
export async function GET(request: NextRequest) {
const title = request.nextUrl.searchParams.get("title") ?? "My App";
return new ImageResponse(
(
<div
style={{
fontSize: 60,
color: "white",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 60,
textAlign: "center",
}}
>
{title}
</div>
),
{
width: 1200,
height: 630,
}
);
}Análisis profundo
Cómo funciona
- La API de Metadatos es la forma principal de establecer etiquetas
<head>en App Router. Exporta un objetometadatao una funcióngenerateMetadatadesdepage.tsxolayout.tsx. - Los metadatos se fusionan por el árbol de componentes. Los metadatos secundarios anulan los metadatos primarios. El patrón
title.templateen el layout raíz ("%s | My App") se aplica a los títulos de las páginas secundarias. generateMetadatarecibe metadatos primarios resueltos a través del segundo argumento. Esto te permite extender las imágenes Open Graph primarias u otros valores heredados.sitemap.tsyrobots.tsson convenciones de archivo especiales. Next.js los sirve en/sitemap.xmly/robots.txtautomáticamente.metadataBaseestablece la URL base para todas las URLs de metadatos relativos (imágenes Open Graph, URLs canónicas). Siempre establécelo en el layout raíz.ImageResponsedenext/oggenera imágenes Open Graph dinámicas usando JSX en el borde. Utiliza Satori bajo el capó, que admite un subconjunto de CSS (solo flexbox, sin grid).
Variaciones
Múltiples Sitemaps (Sitios grandes):
// app/sitemap/[id]/route.ts
import { NextRequest } from "next/server";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const page = parseInt(id, 10);
const perPage = 50000;
const posts = await db.post.findMany({
skip: page * perPage,
take: perPage,
select: { slug: true, updatedAt: true },
});
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${posts.map((post) => `
<url>
<loc>https://myapp.com/posts/${post.slug}</loc>
<lastmod>${post.updatedAt.toISOString()}</lastmod>
</url>`).join("")}
</urlset>`;
return new Response(xml, {
headers: { "Content-Type": "application/xml" },
});
}URLs canónicas:
export const metadata: Metadata = {
alternates: {
canonical: "/posts/my-post",
languages: {
"en-US": "/en/posts/my-post",
"de-DE": "/de/posts/my-post",
},
},
};Notas de TypeScript
- Importa
MetadatayResolvingMetadatadesde"next"para inferencia de tipos completa. MetadataRoute.Sitemapes un array de objetos conurl,lastModified,changeFrequencyypriority.- Usa el paquete
schema-dtspara datos JSON-LD estructurados tipados. - Los parámetros de
generateMetadatason asincronos en Next.js 15+ (el mismo patrónPromise<{ slug: string }>que en las páginas).
Tramillas
generateMetadatase ejecuta antes del componente de página. Su obtención de datos se deduplica con la obtención de datos de la página si se solicita la misma URL, pero la función misma se ejecuta de manera separada.metadataBasedebe ser una URL absoluta. Las URLs relativas en imágenes Open Graph se romperán sin ella.- Los Client Components no pueden exportar metadatos. El objeto
metadatay la funcióngenerateMetadatasolo funcionan en Server Components (page.tsxylayout.tsx). ImageResponsesolo admite flexbox. CSS Grid,position: absolute(con excepciones) y muchas propiedades de CSS no son compatibles con Satori.- Los archivos de mapa de sitio deben devolver todas las URLs. No hay paginación incorporada. Para sitios con más de 50,000 URLs, implementa un índice de mapa de sitio con múltiples archivos de mapa de sitio manualmente.
title.templatesolo se aplica a las páginas secundarias, no a la página donde se define. La página misma usatitle.default.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| API de Metadatos (incorporada) | Seguridad de tipos, automática, colocada | No se puede usar en Client Components |
| Paquete next-seo | API familiar de la era del Pages Router | Redundante con la API de Metadatos incorporada |
Etiquetas <head> manuales | Control total | Sin seguridad de tipos, fácil de perder etiquetas |
schema-dts para JSON-LD | Datos estructurados tipados | Dependencia adicional |
Paquete next-sitemap | Generación automática, soporte ISR | Dependencia adicional, gastos generales de configuración |
Preguntas frecuentes
¿Cuál es la diferencia entre la exportación metadata y generateMetadata?
metadataes un objeto estático para páginas con metadatos fijos (p. ej., la página de inicio).generateMetadataes una función asincronónica para páginas donde los metadatos dependen de datos dinámicos (p. ej., una publicación de blog).- Ambos se exportan desde
page.tsxolayout.tsxy solo son Server Component.
¿Cómo funciona la fusión de metadatos en el árbol de componentes?
- Los metadatos secundarios anulan los metadatos primarios para los mismos campos.
- El
title.templateen el layout raíz (p. ej.,"%s | My App") se aplica a los títulos de las páginas secundarias. title.templatesolo se aplica a las páginas secundarias, no a la página donde se define.
¿Qué es metadataBase y por qué es obligatorio?
metadataBaseestablece la URL base para todas las URLs de metadatos relativos (imágenes Open Graph, URLs canónicas).- Sin ella, las URLs relativas en las etiquetas Open Graph se romperán.
- Establécelo en tu URL de producción en el layout raíz:
metadataBase: new URL("https://myapp.com").
¿Cómo funcionan sitemap.ts y robots.ts como convenciones de archivo?
- Exportar una función predeterminada desde
app/sitemap.tsla sirve automáticamente en/sitemap.xml. - Exportar una función predeterminada desde
app/robots.tsla sirve automáticamente en/robots.txt. - Ambas pueden ser asincronónicas y obtener datos de una base de datos.
Tramilla: ¿Pueden los Client Components exportar metadata o generateMetadata?
- No. La API de Metadatos solo funciona en Server Components.
metadataygenerateMetadatadeben exportarse desdepage.tsxolayout.tsxsin la directiva"use client".
¿Qué limitaciones de CSS tiene ImageResponse (Satori) para imágenes Open Graph dinámicas?
- Solo se admite el diseño flexbox; CSS Grid no funciona.
position: absolutetiene soporte limitado.- Muchas propiedades de CSS no son compatibles; mantén los estilos de imagen Open Graph simples.
¿Cómo agregas datos JSON-LD estructurados a una página?
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLdObject),
}}
/>- Renderiza una etiqueta
<script type="application/ld+json">en el componente de página. - Usa el paquete
schema-dtspara objetos JSON-LD tipados.
¿Cómo escribes con tipos los parámetros de generateMetadata en TypeScript para Next.js 15+?
import type { Metadata, ResolvingMetadata } from "next";
type Props = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
const { slug } = await params;
// ...
}paramses unaPromiseen Next.js 15+ y debe esperarse.
¿Qué es ResolvingMetadata y cómo la usas?
- Es el segundo argumento de
generateMetadata, que proporciona metadatos primarios resueltos. - Úsalo para extender imágenes Open Graph primarias:
const prev = (await parent).openGraph?.images ?? []. - Esto permite que las páginas secundarias hereden y aumenten los metadatos primarios.
¿Cómo manejas mapas de sitio para sitios con más de 50,000 URLs?
- La convención incorporada
sitemap.tsno tiene soporte de paginación. - Implementa un índice de mapa de sitio con múltiples archivos de mapa de sitio usando Route Handlers.
- Cada archivo de mapa de sitio debe contener hasta 50,000 URLs según la especificación del protocolo de mapa de sitio.
Tramilla: ¿Se ejecuta generateMetadata antes o después del componente de página?
generateMetadatase ejecuta antes del componente de página.- Si ambos obtienen la misma URL, Next.js deduplica la solicitud.
- Sin embargo, la función misma se ejecuta de manera separada desde la renderización de la página.
¿Cómo escribes con tipos un objeto JSON-LD estructurado usando schema-dts?
import type { WithContext, Article } from "schema-dts";
const jsonLd: WithContext<Article> = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
datePublished: post.createdAt.toISOString(),
};schema-dtsproporciona tipos de TypeScript para todos los tipos de Schema.org.
Relacionado
- Internacionalización - hreflang y metadatos específicos de configuración regional
- Despliegue - asegurar que los metadatos funcionen en producción
- Manejadores de rutas de API - rutas de imagen Open Graph dinámica
- Documentos de metadatos de Next.js