SEO y Metadatos en Next.js

SEO y Metadatos en Next.js

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.

Metadata API Básica

Next.js ofrece una API de metadatos que permite definir metadatos de forma estática o dinámica en cada layout y página.

Metadatos Estáticos

typescript
// 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>
)
}

Metadatos en Páginas Específicas

typescript
// 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.

Etiqueta Title Dinámica

Title Template

typescript
// 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"
}

Title Absoluto

typescript
// app/landing/page.tsx
export const metadata: Metadata = {
title: {
  absolute: 'Landing Page Especial', // No usa el template
},
}

Metadatos Dinámicos con generateMetadata

Para contenido dinámico, usa generateMetadata que recibe parámetros de la ruta.

typescript
// 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.

Open Graph y Twitter Cards

Open Graph Completo

typescript
// 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 para E-commerce

typescript
// 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',
},
}

Robots y Directivas SEO

Robots Meta

typescript
// 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,
  },
},
}

Canonical URLs

typescript
// 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',
  },
},
}

Sitemap.xml Automático

Next.js puede generar sitemaps automáticamente.

typescript
// 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,
]
}

Robots.txt Automático

typescript
// 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',
}
}

JSON-LD Structured Data

typescript
// 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.

Verificación y Herramientas SEO

Verificación de Metadatos

typescript
// 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.