El SEO (Search Engine Optimization) es fundamental para el éxito de cualquier aplicación web. Next.js 13+ con App Router proporciona herramientas poderosas para gestionar metadatos de forma eficiente y optimizada.
Next.js ofrece una API de metadatos que permite definir metadatos de forma estática o dinámica en cada layout y página.
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Mi Aplicación Next.js',
description: 'Una aplicación increíble construida con Next.js',
keywords: ['Next.js', 'React', 'TypeScript', 'SEO'],
authors: [{ name: 'Tu Nombre' }],
creator: 'Tu Nombre',
publisher: 'Tu Empresa',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="es">
<body>{children}</body>
</html>
)
} // app/blog/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Blog - Mi Aplicación',
description: 'Artículos y tutoriales sobre desarrollo web',
openGraph: {
title: 'Blog - Mi Aplicación',
description: 'Artículos y tutoriales sobre desarrollo web',
url: 'https://miapp.com/blog',
siteName: 'Mi Aplicación',
images: [
{
url: 'https://miapp.com/og-blog.jpg',
width: 800,
height: 600,
},
],
locale: 'es_ES',
type: 'website',
},
}
export default function BlogPage() {
return (
<div>
<h1>Mi Blog</h1>
{/* Contenido del blog */}
</div>
)
} La Metadata API de Next.js se fusiona automáticamente desde el layout raíz hasta la página específica, permitiendo herencia y sobrescritura de metadatos.
// app/layout.tsx
export const metadata: Metadata = {
title: {
template: '%s | Mi Aplicación',
default: 'Mi Aplicación - Desarrollo Web',
},
description: 'Aplicación web moderna con Next.js',
}
// app/productos/page.tsx
export const metadata: Metadata = {
title: 'Productos', // Se convierte en "Productos | Mi Aplicación"
}
// app/contacto/page.tsx
export const metadata: Metadata = {
title: 'Contacto', // Se convierte en "Contacto | Mi Aplicación"
} // app/landing/page.tsx
export const metadata: Metadata = {
title: {
absolute: 'Landing Page Especial', // No usa el template
},
} Para contenido dinámico, usa generateMetadata que recibe parámetros de la ruta.
// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next'
type Props = {
params: { slug: string }
searchParams: { [key: string]: string | string[] | undefined }
}
export async function generateMetadata(
{ params, searchParams }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
// Obtener datos del post
const post = await getPost(params.slug)
// Obtener metadatos del padre
const previousImages = (await parent).openGraph?.images || []
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.image, ...previousImages],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.image],
},
}
}
async function getPost(slug: string) {
// Lógica para obtener el post
return {
title: 'Mi Post Increíble',
excerpt: 'Descripción del post...',
image: 'https://miapp.com/posts/mi-post.jpg',
}
}
export default function PostPage({ params }: Props) {
return <div>Contenido del post {params.slug}</div>
} generateMetadata se ejecuta en el servidor y puede usar async/await para obtener datos. Los metadatos se generan antes del renderizado de la página.
// app/productos/[id]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const producto = await getProducto(params.id)
return {
title: producto.nombre,
description: producto.descripcion,
openGraph: {
title: producto.nombre,
description: producto.descripcion,
url: `https://mitienda.com/productos/${params.id}`,
siteName: 'Mi Tienda Online',
images: [
{
url: producto.imagen,
width: 800,
height: 600,
alt: producto.nombre,
},
{
url: producto.imagenSecundaria,
width: 1800,
height: 1600,
alt: `${producto.nombre} - Vista detallada`,
},
],
locale: 'es_ES',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: producto.nombre,
description: producto.descripcion,
creator: '@mitienda',
images: {
url: producto.imagen,
alt: producto.nombre,
},
},
}
} // Metadatos específicos para productos
export const metadata: Metadata = {
title: 'Producto Increíble',
description: 'El mejor producto del mercado',
openGraph: {
title: 'Producto Increíble',
description: 'El mejor producto del mercado',
type: 'product',
images: ['/producto-imagen.jpg'],
},
other: {
'product:price:amount': '99.99',
'product:price:currency': 'EUR',
'product:availability': 'in stock',
'product:condition': 'new',
},
} // app/admin/layout.tsx - Área privada
export const metadata: Metadata = {
title: 'Panel de Administración',
robots: {
index: false,
follow: false,
nocache: true,
googleBot: {
index: false,
follow: false,
noimageindex: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
}
// app/blog/page.tsx - Contenido público
export const metadata: Metadata = {
title: 'Blog',
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
} // app/productos/page.tsx
export const metadata: Metadata = {
title: 'Productos',
alternates: {
canonical: 'https://mitienda.com/productos',
languages: {
'es-ES': 'https://mitienda.com/es/productos',
'en-US': 'https://mitienda.com/en/products',
},
},
} Next.js puede generar sitemaps automáticamente.
// app/sitemap.ts
import { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: 'https://miapp.com',
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 1,
},
{
url: 'https://miapp.com/blog',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: 'https://miapp.com/productos',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.5,
},
]
}
// Sitemap dinámico
export default async function sitemap(): MetadataRoute.Sitemap {
// Obtener posts del blog
const posts = await getPosts()
const postEntries: MetadataRoute.Sitemap = posts.map((post) => ({
url: `https://miapp.com/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'monthly',
priority: 0.7,
}))
return [
{
url: 'https://miapp.com',
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 1,
},
...postEntries,
]
} // app/robots.ts
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/admin/', '/api/private/'],
},
sitemap: 'https://miapp.com/sitemap.xml',
}
}
// Robots.txt con múltiples reglas
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: 'Googlebot',
allow: ['/'],
disallow: ['/admin/'],
},
{
userAgent: ['Applebot', 'Bingbot'],
disallow: ['/'],
},
],
sitemap: 'https://miapp.com/sitemap.xml',
}
} // app/productos/[id]/page.tsx
export default async function ProductPage({ params }: Props) {
const producto = await getProducto(params.id)
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: producto.nombre,
image: producto.imagen,
description: producto.descripcion,
sku: producto.sku,
mpn: producto.mpn,
brand: {
'@type': 'Brand',
name: producto.marca,
},
offers: {
'@type': 'Offer',
url: `https://mitienda.com/productos/${params.id}`,
priceCurrency: 'EUR',
price: producto.precio,
priceValidUntil: '2024-12-31',
itemCondition: 'https://schema.org/NewCondition',
availability: 'https://schema.org/InStock',
},
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div>
<h1>{producto.nombre}</h1>
{/* Contenido del producto */}
</div>
</>
)
} Los metadatos se procesan en el servidor, no afectan el bundle del cliente. Next.js optimiza automáticamente la carga y el renderizado de metadatos.
// utils/seo-helpers.ts
export function generatePageMetadata({
title,
description,
image,
url,
}: {
title: string
description: string
image?: string
url?: string
}) {
return {
title,
description,
openGraph: {
title,
description,
url,
images: image ? [{ url: image }] : undefined,
},
twitter: {
card: 'summary_large_image',
title,
description,
images: image ? [image] : undefined,
},
}
}
// Uso en páginas
export const metadata = generatePageMetadata({
title: 'Mi Página',
description: 'Descripción de mi página',
image: '/mi-imagen.jpg',
url: 'https://miapp.com/mi-pagina',
}) Usa herramientas como Google Search Console, Facebook Debugger y Twitter Card Validator para verificar que tus metadatos se muestren correctamente.
Siempre incluye atributos alt en las imágenes de Open Graph y asegúrate de que los metadatos sean descriptivos y útiles para usuarios con lectores de pantalla.
Los metadatos dinámicos con generateMetadata permiten SEO personalizado por página, pero recuerda que se ejecutan en cada request. Usa caché cuando sea posible para optimizar performance.