//
Busca en todas las páginas de la documentación
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
// 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>
);
}// 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>;
}// 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];
}// 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",
};
}// 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>
</>
);
}// 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,
}
);
}<head> en App Router. Exporta un objeto metadata o una función generateMetadata desde page.tsx o layout.tsx.title.template en el layout raíz ("%s | My App") se aplica a los títulos de las páginas secundarias.generateMetadata recibe metadatos primarios resueltos a través del segundo argumento. Esto te permite extender las imágenes Open Graph primarias u otros valores heredados.sitemap.ts y robots.ts son convenciones de archivo especiales. Next.js los sirve en /sitemap.xml y /robots.txt automáticamente.metadataBase establece 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.ImageResponse de next/og genera 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).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",
},
},
};Metadata y ResolvingMetadata desde "next" para inferencia de tipos completa.MetadataRoute.Sitemap es un array de objetos con url, lastModified, changeFrequency y priority.schema-dts para datos JSON-LD estructurados tipados.generateMetadata son asincronos en Next.js 15+ (el mismo patrón Promise<{ slug: string }> que en las páginas).generateMetadata se 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.metadataBase debe ser una URL absoluta. Las URLs relativas en imágenes Open Graph se romperán sin ella.metadata y la función generateMetadata solo funcionan en Server Components (page.tsx y layout.tsx).ImageResponse solo admite flexbox. CSS Grid, position: absolute (con excepciones) y muchas propiedades de CSS no son compatibles con Satori.title.template solo se aplica a las páginas secundarias, no a la página donde se define. La página misma usa title.default.| 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 |
metadata es un objeto estático para páginas con metadatos fijos (p. ej., la página de inicio).generateMetadata es una función asincronónica para páginas donde los metadatos dependen de datos dinámicos (p. ej., una publicación de blog).page.tsx o layout.tsx y solo son Server Component.title.template en el layout raíz (p. ej., "%s | My App") se aplica a los títulos de las páginas secundarias.title.template solo se aplica a las páginas secundarias, no a la página donde se define.metadataBase establece la URL base para todas las URLs de metadatos relativos (imágenes Open Graph, URLs canónicas).metadataBase: new URL("https://myapp.com").app/sitemap.ts la sirve automáticamente en /sitemap.xml.app/robots.ts la sirve automáticamente en /robots.txt.metadata y generateMetadata deben exportarse desde page.tsx o layout.tsx sin la directiva "use client".position: absolute tiene soporte limitado.<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLdObject),
}}
/><script type="application/ld+json"> en el componente de página.schema-dts para objetos JSON-LD tipados.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;
// ...
}params es una Promise en Next.js 15+ y debe esperarse.generateMetadata, que proporciona metadatos primarios resueltos.const prev = (await parent).openGraph?.images ?? [].sitemap.ts no tiene soporte de paginación.generateMetadata se ejecuta antes del componente de página.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-dts proporciona tipos de TypeScript para todos los tipos de Schema.org.Revisado por Chris St. John·Última actualización: 7 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥