Metadata - SEO y compartir

Metadata es la información sobre tu página que ven los motores de búsqueda y redes sociales. Un buen metadata mejora tu SEO y hace que tus links se vean bien al compartirlos.

¿Qué es metadata?

Son tags HTML en el <head> de tu página:

<head>
  <title>Mi Tienda - Los mejores productos</title>
  <meta name="description" content="Compra productos de calidad...">
  <meta property="og:image" content="https://mitienda.com/og-image.jpg">
</head>

Estos tags no los ve el usuario, pero sí:

  • Google (para ranking)
  • Facebook (cuando compartes un link)
  • Twitter (preview de links)
  • WhatsApp (preview de links)

Metadata estático

Para páginas con metadata fijo:

// app/page.tsx
import { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'Mi Tienda - Los mejores productos',
  description: 'Compra productos de alta calidad a los mejores precios',
}

export default function HomePage() {
  return <div>Contenido</div>
}

NextJS automáticamente genera:

<head>
  <title>Mi Tienda - Los mejores productos</title>
  <meta name="description" content="Compra productos de alta calidad...">
</head>

Metadata dinámico

Para páginas con contenido dinámico:

// app/productos/[id]/page.tsx
import { Metadata } from 'next'

type Props = {
  params: { id: string }
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  // Obtener datos del producto
  const producto = await fetch(`https://api.mitienda.com/productos/${params.id}`)
    .then(res => res.json())
  
  return {
    title: `${producto.nombre} - Mi Tienda`,
    description: producto.descripcion,
    openGraph: {
      images: [producto.imagen],
    },
  }
}

export default function ProductoPage({ params }: Props) {
  return <div>Página del producto</div>
}

NextJS espera a que generateMetadata termine antes de enviar el HTML. Esto significa que Google ve el metadata correcto.

Propiedades básicas

title

export const metadata = {
  title: 'Mi Página',
}

Genera:

<title>Mi Página</title>

description

export const metadata = {
  description: 'Esta es una descripción de mi página para SEO',
}

Genera:

<meta name="description" content="Esta es una descripción...">
💡
Longitud ideal
  • Title: 50-60 caracteres (lo que muestra Google)
  • Description: 150-160 caracteres

Si es más largo, Google lo corta con "..."

keywords (obsoleto)

export const metadata = {
  keywords: ['tienda', 'productos', 'ecommerce'],
}

No lo uses. Google no usa keywords desde 2009. Es spam.

authors

export const metadata = {
  authors: [{ name: 'Juan Pérez', url: 'https://juanperez.com' }],
}

creator

export const metadata = {
  creator: 'Juan Pérez',
}

Template de títulos

Para evitar repetir el nombre del sitio:

// app/layout.tsx
export const metadata = {
  title: {
    default: 'Mi Tienda',
    template: '%s | Mi Tienda',
  },
}
// app/productos/page.tsx
export const metadata = {
  title: 'Productos',  // Resultado: "Productos | Mi Tienda"
}
// app/contacto/page.tsx
export const metadata = {
  title: 'Contacto',  // Resultado: "Contacto | Mi Tienda"
}

Absolute title

Para páginas que quieres título completo:

// app/page.tsx
export const metadata = {
  title: {
    absolute: 'Mi Tienda - Los mejores productos online',
  },
  // Ignora el template, usa este título exacto
}

Open Graph (Facebook, WhatsApp)

Open Graph es metadata para redes sociales:

export const metadata = {
  title: 'Mi Producto Increíble',
  description: 'Este producto es genial porque...',
  openGraph: {
    title: 'Mi Producto Increíble',
    description: 'Este producto es genial porque...',
    url: 'https://mitienda.com/productos/1',
    siteName: 'Mi Tienda',
    images: [
      {
        url: 'https://mitienda.com/og-image.jpg',
        width: 1200,
        height: 630,
        alt: 'Mi Producto Increíble',
      },
    ],
    locale: 'es_ES',
    type: 'website',
  },
}

Vista previa cuando compartes en Facebook/WhatsApp:

┌─────────────────────────────────┐
│  [Imagen 1200x630]              │
├─────────────────────────────────┤
│ Mi Producto Increíble           │
│ Este producto es genial porque..│
│ mitienda.com                    │
└─────────────────────────────────┘

Tamaños de imagen recomendados

  • Facebook/WhatsApp: 1200x630px
  • Twitter: 1200x675px
  • LinkedIn: 1200x627px

Formato: JPG o PNG, máximo 8 MB

Twitter Cards

export const metadata = {
  twitter: {
    card: 'summary_large_image',
    title: 'Mi Producto Increíble',
    description: 'Este producto es genial porque...',
    creator: '@mitienda',
    images: ['https://mitienda.com/twitter-image.jpg'],
  },
}

Tipos de card:

  • 'summary': Imagen cuadrada pequeña
  • 'summary_large_image': Imagen grande (recomendado)
  • 'app': Para promover apps
  • 'player': Para videos

Metadata para ecommerce

// app/productos/[id]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
  const producto = await getProducto(params.id)
  
  return {
    title: `${producto.nombre} - Mi Tienda`,
    description: `Compra ${producto.nombre} por solo $${producto.precio}. ${producto.descripcionCorta}`,
    
    openGraph: {
      title: producto.nombre,
      description: producto.descripcionCorta,
      url: `https://mitienda.com/productos/${producto.id}`,
      siteName: 'Mi Tienda',
      images: [
        {
          url: producto.imagen,
          width: 1200,
          height: 630,
          alt: producto.nombre,
        },
      ],
      type: 'website',
    },
    
    twitter: {
      card: 'summary_large_image',
      title: producto.nombre,
      description: producto.descripcionCorta,
      images: [producto.imagen],
    },
    
    // Para Google Shopping
    other: {
      'product:price:amount': producto.precio.toString(),
      'product:price:currency': 'USD',
      'product:availability': producto.enStock ? 'in stock' : 'out of stock',
      'product:condition': 'new',
    },
  }
}

Metadata para blog

// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
  const post = await getPost(params.slug)
  
  return {
    title: post.titulo,
    description: post.extracto,
    authors: [{ name: post.autor }],
    
    openGraph: {
      title: post.titulo,
      description: post.extracto,
      url: `https://miblog.com/blog/${post.slug}`,
      siteName: 'Mi Blog',
      images: [post.imagenDestacada],
      type: 'article',
      publishedTime: post.fechaPublicacion,
      authors: [post.autor],
    },
    
    twitter: {
      card: 'summary_large_image',
      title: post.titulo,
      description: post.extracto,
      images: [post.imagenDestacada],
    },
  }
}

Icons y favicons

// app/layout.tsx
export const metadata = {
  icons: {
    icon: '/favicon.ico',
    shortcut: '/favicon-16x16.png',
    apple: '/apple-touch-icon.png',
    other: {
      rel: 'apple-touch-icon-precomposed',
      url: '/apple-touch-icon-precomposed.png',
    },
  },
}

Estructura recomendada:

public/
├── favicon.ico           (32x32)
├── favicon-16x16.png
├── favicon-32x32.png
├── apple-touch-icon.png  (180x180)
└── android-chrome-192x192.png

Con route handlers

// app/icon.png/route.tsx
import { ImageResponse } from 'next/og'

export function GET() {
  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 24,
          background: 'black',
          width: '100%',
          height: '100%',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          color: 'white',
        }}
      >
        MT
      </div>
    ),
    {
      width: 32,
      height: 32,
    }
  )
}

Robots y indexación

robots.txt

// app/robots.ts
import { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
      disallow: '/admin/',
    },
    sitemap: 'https://mitienda.com/sitemap.xml',
  }
}

Genera:

User-Agent: *
Allow: /
Disallow: /admin/
Sitemap: https://mitienda.com/sitemap.xml

Meta robots

export const metadata = {
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      'max-video-preview': -1,
      'max-image-preview': 'large',
      'max-snippet': -1,
    },
  },
}

No index (páginas privadas)

// app/admin/page.tsx
export const metadata = {
  robots: {
    index: false,
    follow: false,
  },
}

Sitemap

// app/sitemap.ts
import { MetadataRoute } from 'next'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const productos = await fetch('https://api.mitienda.com/productos')
    .then(res => res.json())
  
  const productosUrls = productos.map((producto) => ({
    url: `https://mitienda.com/productos/${producto.id}`,
    lastModified: producto.updatedAt,
    changeFrequency: 'daily' as const,
    priority: 0.8,
  }))
  
  return [
    {
      url: 'https://mitienda.com',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1,
    },
    {
      url: 'https://mitienda.com/productos',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 0.9,
    },
    ...productosUrls,
  ]
}

Genera automáticamente: https://mitienda.com/sitemap.xml

Canonical URLs

Para evitar contenido duplicado:

export const metadata = {
  alternates: {
    canonical: 'https://mitienda.com/productos/camisa-azul',
  },
}

Si tienes múltiples URLs para el mismo contenido:

  • https://mitienda.com/productos/1
  • https://mitienda.com/p/1
  • https://mitienda.com/productos/camisa-azul

Define una como canonical para decirle a Google cuál es la "oficial".

Alternate languages

Para sitios multiidioma:

export const metadata = {
  alternates: {
    canonical: 'https://mitienda.com/productos/1',
    languages: {
      'es-ES': 'https://mitienda.com/es/productos/1',
      'en-US': 'https://mitienda.com/en/products/1',
      'pt-BR': 'https://mitienda.com/pt/produtos/1',
    },
  },
}

Verification (verificación)

Para verificar tu sitio con Google, Bing, etc:

export const metadata = {
  verification: {
    google: 'google123abc',
    yandex: 'yandex123abc',
    yahoo: 'yahoo123abc',
    other: {
      me: ['email@example.com', 'https://example.com'],
    },
  },
}

Para apps móviles:

export const metadata = {
  appLinks: {
    ios: {
      url: 'https://mitienda.com/productos/1',
      app_store_id: 'app_store_id',
    },
    android: {
      package: 'com.example.android/products/1',
      app_name: 'Mi Tienda',
    },
    web: {
      url: 'https://mitienda.com/productos/1',
    },
  },
}

Viewport y theme color

// app/layout.tsx
import { Metadata, Viewport } from 'next'

export const viewport: Viewport = {
  width: 'device-width',
  initialScale: 1,
  maximumScale: 1,
}

export const metadata: Metadata = {
  themeColor: [
    { media: '(prefers-color-scheme: light)', color: '#ffffff' },
    { media: '(prefers-color-scheme: dark)', color: '#000000' },
  ],
}

Metadata completo (ejemplo)

// app/productos/[id]/page.tsx
import { Metadata } from 'next'

type Props = {
  params: { id: string }
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const producto = await fetch(`https://api.mitienda.com/productos/${params.id}`)
    .then(res => res.json())
  
  const url = `https://mitienda.com/productos/${params.id}`
  
  return {
    title: `${producto.nombre} - Mi Tienda`,
    description: `Compra ${producto.nombre} por solo $${producto.precio}. ${producto.descripcionCorta}`,
    
    keywords: [producto.nombre, producto.categoria, 'tienda online'],
    
    authors: [{ name: 'Mi Tienda' }],
    
    openGraph: {
      title: producto.nombre,
      description: producto.descripcionCorta,
      url,
      siteName: 'Mi Tienda',
      images: [
        {
          url: producto.imagen,
          width: 1200,
          height: 630,
          alt: producto.nombre,
        },
      ],
      locale: 'es_ES',
      type: 'website',
    },
    
    twitter: {
      card: 'summary_large_image',
      title: producto.nombre,
      description: producto.descripcionCorta,
      creator: '@mitienda',
      images: [producto.imagen],
    },
    
    robots: {
      index: producto.visible,
      follow: true,
    },
    
    alternates: {
      canonical: url,
    },
  }
}

export default async function ProductoPage({ params }: Props) {
  const producto = await getProducto(params.id)
  
  return (
    <div>
      <h1>{producto.nombre}</h1>
      <p>${producto.precio}</p>
    </div>
  )
}

Herramientas para probar

Open Graph

Twitter

Google

LinkedIn

Mejores prácticas SEO

1. Title único por página

// ❌ Mal: Mismo title en todas
export const metadata = {
  title: 'Mi Tienda',
}

// ✅ Bien: Title descriptivo único
export const metadata = {
  title: 'Camisa Azul de Algodón - Talla M | Mi Tienda',
}

2. Description que invita a click

// ❌ Mal: Genérico
description: 'Venta de productos online'

// ✅ Bien: Específico y con llamado a acción
description: 'Camisa azul de 100% algodón. Envío gratis en compras sobre $50. ¡Compra ahora con 20% de descuento!'

3. Imágenes Open Graph de calidad

// ❌ Mal: Imagen muy pequeña o sin especificar dimensiones
images: ['https://mitienda.com/logo.png']

// ✅ Bien: 1200x630, con alt text
images: [
  {
    url: 'https://mitienda.com/og-images/producto-1.jpg',
    width: 1200,
    height: 630,
    alt: 'Camisa azul de algodón sobre fondo blanco',
  },
]

4. URLs canónicas

// Siempre define canonical
alternates: {
  canonical: 'https://mitienda.com/productos/camisa-azul',
}

5. Sitemap actualizado

Regenera tu sitemap cuando agregues/elimines páginas:

// Sitemap dinámico que se actualiza automáticamente
export default async function sitemap() {
  const productos = await getProductosActivos()
  return productos.map(p => ({
    url: `https://mitienda.com/productos/${p.id}`,
    lastModified: p.updatedAt,
  }))
}

Errores comunes

Error: Metadata duplicado

// ❌ Mal: Metadata en layout Y en página duplica
// app/layout.tsx
export const metadata = {
  title: 'Mi Tienda',
  description: 'Descripción global',
}

// app/page.tsx
export const metadata = {
  title: 'Mi Tienda',  // Se sobrescribe
  description: 'Descripción global',  // Se sobrescribe
}

Solución: Usa template en layout, valores específicos en páginas.

Error: generateMetadata sin await

// ❌ Mal: Olvidas await
export async function generateMetadata({ params }) {
  const producto = fetch(`/api/productos/${params.id}`).then(r => r.json())
  
  return {
    title: producto.nombre,  // Error: producto es Promise
  }
}

// ✅ Bien: Con await
export async function generateMetadata({ params }) {
  const producto = await fetch(`/api/productos/${params.id}`).then(r => r.json())
  
  return {
    title: producto.nombre,  // Funciona
  }
}

Error: Imagen Open Graph sin dimensiones

// ❌ Mal: Sin width/height
openGraph: {
  images: ['https://mitienda.com/image.jpg'],
}

// ✅ Bien: Con dimensiones
openGraph: {
  images: [
    {
      url: 'https://mitienda.com/image.jpg',
      width: 1200,
      height: 630,
    },
  ],
}

Recursos

  • Google Search Central: Guías de SEO oficiales
  • Schema.org: Structured data
  • Open Graph Protocol: Documentación oficial
  • Twitter Cards: Documentación de Twitter

Resumen

Metadata en NextJS:

  • Define con export const metadata (estático)
  • O con export async function generateMetadata (dinámico)
  • Incluye title, description, Open Graph, Twitter
  • Genera sitemap y robots.txt con route handlers

SEO básico:

  • Title único: 50-60 caracteres
  • Description: 150-160 caracteres
  • Open Graph image: 1200x630px
  • Canonical URLs
  • Sitemap actualizado

Herramientas:

  • Facebook Sharing Debugger
  • Twitter Card Validator
  • Google Rich Results Test

Regla de oro: Cada página debe tener title, description, e imagen Open Graph únicos y descriptivos. Esto mejora tu SEO y hace que tus links se vean bien al compartir.