Metadata y SEO en Next.js

Cómo usar la API de Metadata de Next.js para controlar títulos, descripciones, Open Graph y Twitter Cards, con metadata estática y dinámica con generateMetadata.

Metadata y SEO en Next.js

Serie: Entendiendo Next.js — Este artículo es parte de la serie "Entendiendo Next.js", donde exploramos cómo funciona Next.js internamente.

El SEO es una de las razones principales por las que la gente elige Next.js. Cuando el HTML llega pre-renderizado, Google puede indexar el contenido inmediatamente, sin depender de la fase de renderizado de JavaScript. Pero eso solo es la mitad del trabajo: también necesitás controlar qué aparece en los <meta> tags, qué imagen muestra Twitter cuando alguien comparte tu URL, y qué título ve Google en los resultados.

Para eso existe la API de Metadata de Next.js: nativa, tipada, y sin instalar nada.

¿Por qué Next.js es tan bueno para el SEO?

Tres razones concretas:

1. El HTML llega pre-renderizado. Los Server Components generan HTML en el servidor. Google sí ejecuta JavaScript, pero en una segunda fase posterior al crawling. Con SSR, puede indexar el contenido en la primera pasada, sin depender de ese paso extra.

2. La API de Metadata es nativa. No necesitás react-helmet, next/head, ni ninguna librería externa. Exportás un objeto desde tu page.tsx o layout.tsx y Next.js se encarga de colocar los tags en el <head>.

3. Restricción clave. La API de Metadata solo funciona en Server Components. No podés exportar metadata ni generateMetadata desde un archivo con 'use client'. Esta restricción existe porque el HTML del <head> se genera en el servidor antes de que el cliente ejecute nada.

Metadata estática: export const metadata

La forma más simple es exportar un objeto metadata directamente desde un layout.tsx o page.tsx.

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

export const metadata: Metadata = {
  metadataBase: new URL('https://acme.com'),
  title: 'Mi página',
  description: 'Descripción de la página para Google',
  keywords: ['Next.js', 'React', 'TypeScript'],
  authors: [{ name: 'Ema', url: 'https://emanu.dev' }],
  robots: { index: true, follow: true },
  openGraph: {
    title: 'Mi página',
    description: 'Descripción de la página para redes sociales',
    url: 'https://acme.com',
    siteName: 'Acme',
    images: [{ url: '/og.png', width: 1200, height: 630, alt: 'Descripción de la imagen' }],
    locale: 'es_AR',
    type: 'website',
  },
  twitter: {
    card: 'summary_large_image',
    title: 'Mi página',
    description: 'Descripción para Twitter',
    creator: '@handle',
    images: ['https://acme.com/og.png'],
  },
}

Esto genera en el <head> del HTML:

html
<title>Mi página</title>
<meta name="description" content="Descripción de la página para Google" />
<meta name="keywords" content="Next.js,React,TypeScript" />
<meta property="og:title" content="Mi página" />
<meta property="og:description" content="Descripción de la página para redes sociales" />
<meta property="og:image" content="https://acme.com/og.png" />
<meta name="twitter:card" content="summary_large_image" />
<!-- etc. -->

Los campos principales

metadataBase

Define la URL base del sitio. Es importante porque Next.js la usa para convertir URLs relativas en absolutas en los campos de OG y Twitter. Sin metadataBase, una imagen como /og.png quedaría como una URL relativa inválida en los meta tags.

tsx
metadataBase: new URL('https://acme.com')
// /og.png → https://acme.com/og.png

robots

Controla cómo los crawlers indexan tu página.

tsx
robots: {
  index: true,   // Google puede indexar esta página
  follow: true,  // Google puede seguir los links de esta página
}
// También admite: noindex, nofollow, noarchive, nosnippet

formatDetection

Por defecto, algunos navegadores detectan automáticamente números de teléfono y emails en el texto y los convierten en links. Podés desactivarlo:

tsx
formatDetection: {
  email: false,
  address: false,
  telephone: false,
}

Title template: herencia de títulos

Uno de los features más útiles es poder definir un template de título en el layout raíz y que las páginas hijas solo especifiquen su parte del título.

tsx
// app/layout.tsx — layout raíz
export const metadata: Metadata = {
  title: {
    template: '%s | Mi Blog',  // %s se reemplaza con el título de la página hija
    default: 'Mi Blog',        // se usa cuando la página hija no define título
  },
}
tsx
// app/blog/primer-post/page.tsx — página hija
export const metadata: Metadata = {
  title: 'Primer post',  // solo necesita su parte
}
// Resultado en el <title>: "Primer post | Mi Blog"

Si una página necesita ignorar el template del padre y definir su título completo, usa absolute:

tsx
// app/landing/page.tsx
export const metadata: Metadata = {
  title: {
    absolute: 'Oferta especial — landing page',  // ignora el template del padre
  },
}
// Resultado: "Oferta especial — landing page"

Open Graph: cómo se ve tu contenido en redes

Open Graph (OG) es un protocolo que define cómo se previsualiza tu página cuando alguien la comparte en redes sociales. Facebook, LinkedIn, Slack, y WhatsApp leen estos tags para armar la preview con imagen, título y descripción.

Para una página genérica (type: 'website'):

tsx
openGraph: {
  type: 'website',
  url: 'https://acme.com/about',
  siteName: 'Acme',
  title: 'Sobre nosotros',
  description: 'Quiénes somos y qué hacemos',
  images: [{
    url: '/og-about.png',
    width: 1200,   // dimensión recomendada
    height: 630,   // dimensión recomendada
    alt: 'Foto del equipo de Acme',
  }],
  locale: 'es_AR',
}

Para un post de blog (type: 'article'):

tsx
openGraph: {
  type: 'article',
  publishedTime: '2026-03-13T00:00:00.000Z',
  authors: ['https://emanu.dev'],
  tags: ['Next.js', 'SEO'],
  // ... resto de campos igual
}

La diferencia entre website y article es semántica: las plataformas que consumen OG pueden mostrar cosas distintas para artículos (como la fecha de publicación).

Dimensiones recomendadas para imágenes OG: 1200×630px. Esta proporción 1.91:1 es la que usan Facebook y LinkedIn para mostrar la imagen en su tamaño máximo.

Twitter Cards

Twitter tiene su propia especificación de metadata, independiente de Open Graph. Aunque Twitter usa OG como fallback si no encontrás los tags twitter:*, es buena práctica definir los dos explícitamente.

Hay dos tipos de card que vas a usar casi siempre:

  • summary: imagen pequeña al costado del texto. Para páginas de perfil, productos sin imagen principal.
  • summary_large_image: imagen grande encima del texto. Para posts de blog, artículos, landings.
tsx
twitter: {
  card: 'summary_large_image',
  title: 'Título del post',
  description: 'Descripción que aparece bajo la imagen en Twitter',
  creator: '@handle',        // cuenta del autor del contenido
  site: '@sitiotwitter',     // cuenta del sitio (opcional)
  images: ['https://acme.com/og-post.png'],
}

Metadata dinámica: generateMetadata()

Cuando el título y la descripción de una página dependen de datos externos (una base de datos, una API), necesitás generateMetadata. Se usa principalmente en rutas dinámicas como app/blog/[slug]/page.tsx.

tsx
// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next'

type Props = {
  params: Promise<{ slug: string }>
}

export async function generateMetadata(
  { params }: Props,
  parent: ResolvingMetadata  // metadata del layout padre, por si necesitás extenderla
): Promise<Metadata> {
  const { slug } = await params  // params es Promise desde Next.js 15
  const post = await fetch(`https://api.acme.com/posts/${slug}`).then(r => r.json())

  // Podés extender las imágenes del padre en lugar de reemplazarlas
  const previousImages = (await parent).openGraph?.images || []

  return {
    title: post.title,
    description: post.excerpt,
    alternates: {
      canonical: `/blog/${slug}`,  // siempre definí la URL canónica
    },
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.publishedAt,
      images: [{ url: post.coverImage, width: 1200, height: 630 }, ...previousImages],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  }
}

Cómo funciona el flujo:

request /blog/mi-post
        │
        ├─▶ generateMetadata({ params: { slug: "mi-post" } })
        │         │
        │         └─▶ fetch("https://api.acme.com/posts/mi-post")
        │                   │
        │                   └─▶ return Metadata (title, description, og, twitter)
        │
        └─▶ Page({ params: { slug: "mi-post" } })
                  │
                  └─▶ fetch("https://api.acme.com/posts/mi-post")  ← mismo fetch
                            │
                            └─▶ return JSX

El mismo fetch no se ejecuta dos veces. Next.js memoiza automáticamente las llamadas a fetch con la misma URL durante un mismo render. generateMetadata y el componente de página comparten el resultado.

Para garantizar la memoización de forma explícita (especialmente si no usás fetch sino un ORM o función custom), podés usar React.cache:

tsx
// lib/posts.ts
import { cache } from 'react'

// cache() asegura que si se llama múltiples veces con el mismo argumento
// durante un render, la función se ejecuta una sola vez
export const getPost = cache(async (slug: string) => {
  return fetch(`https://api.acme.com/posts/${slug}`).then(r => r.json())
})
tsx
// app/blog/[slug]/page.tsx
import { getPost } from '@/lib/posts'

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)  // primera llamada, ejecuta el fetch
  return { title: post.title, description: post.excerpt }
}

export default async function Page({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug)  // reutiliza el resultado cacheado
  return <article>{/* ... */}</article>
}

generateStaticParams + metadata dinámica

En blogs y sitios de contenido, generateMetadata se usa casi siempre junto a generateStaticParams. generateStaticParams le dice a Next.js qué slugs pre-renderizar en build time; generateMetadata se encarga de que cada una de esas páginas tenga los meta tags correctos.

tsx
// app/blog/[slug]/page.tsx

// Pre-renderiza todos los posts en build time
export async function generateStaticParams() {
  const posts = await getPosts()
  return posts.map(post => ({ slug: post.slug }))
}

// Genera la metadata para cada post pre-renderizado (y para los que se generen on-demand)
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
  return { title: post.title, description: post.excerpt }
}

La combinación genera páginas completamente estáticas con metadata incluida: el HTML final ya contiene los <meta> tags correctos sin ningún request en runtime.

Ejemplo real: post de blog

tsx
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { cache } from 'react'

type Props = {
  params: Promise<{ slug: string }>
}

const getPost = cache(async (slug: string) => {
  const res = await fetch(`https://api.acme.com/posts/${slug}`, {
    next: { revalidate: 3600 }  // revalida cada hora
  })
  if (!res.ok) return null
  return res.json()
})

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)

  if (!post) return { title: 'Post no encontrado' }

  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: `/blog/${slug}` },
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.publishedAt,
      authors: [post.author.url],
      images: [{ url: post.coverImage, width: 1200, height: 630, alt: post.title }],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      creator: post.author.twitter,
      images: [post.coverImage],
    },
  }
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug)  // no hace un fetch nuevo

  if (!post) notFound()

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}

Ejemplo real: página de producto (e-commerce)

tsx
// app/products/[id]/page.tsx
import type { Metadata } from 'next'
import { cache } from 'react'

type Props = {
  params: Promise<{ id: string }>
}

const getProduct = cache(async (id: string) => {
  return fetch(`https://api.acme.com/products/${id}`).then(r => r.json())
})

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { id } = await params
  const product = await getProduct(id)

  return {
    title: product.name,
    description: product.description,
    alternates: { canonical: `/products/${id}` },
    openGraph: {
      title: product.name,
      description: product.description,
      type: 'website',  // productos usan 'website', no 'article'
      images: product.images.map((img: { url: string; alt: string }) => ({
        url: img.url,
        width: 1200,
        height: 630,
        alt: img.alt,
      })),
    },
    twitter: {
      card: 'summary_large_image',
      title: product.name,
      description: product.description,
      images: [product.images[0]?.url],
    },
  }
}

Open Graph Images dinámicas: opengraph-image.tsx

Next.js tiene una convención de archivo especial para generar imágenes OG dinámicamente: opengraph-image.tsx. Al igual que page.tsx o layout.tsx, su nombre es lo que lo activa.

app/
└── blog/
    └── [slug]/
        ├── page.tsx
        └── opengraph-image.tsx  ← genera la imagen OG para /blog/[slug]
tsx
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'

// Dimensiones de la imagen generada
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export const alt = 'Imagen del post'

export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const post = await fetch(`https://api.acme.com/posts/${slug}`).then(r => r.json())

  return new ImageResponse(
    // Solo Flexbox — CSS Grid no está soportado
    <div
      style={{
        background: '#0f172a',
        width: '100%',
        height: '100%',
        display: 'flex',
        alignItems: 'center',
        padding: '80px',
      }}
    >
      <h1 style={{ color: 'white', fontSize: 64, lineHeight: 1.2 }}>
        {post.title}
      </h1>
    </div>,
    { ...size }
  )
}

Restricciones a tener en cuenta:

  • Solo soporta Flexbox, no CSS Grid.
  • Las fuentes deben cargarse explícitamente si querés usar algo custom.
  • Se cachea en build time por defecto para rutas estáticas.

Dónde puede vivir la metadata (restricciones)

Contexto¿Puede tener metadata?Notas
layout.tsxmetadata o generateMetadata
page.tsxmetadata o generateMetadata
Client Component ('use client')NoError en build time
Componente arbitrarioNoSolo en page y layout
Mismo archivo con ambosNoNo podés exportar metadata y generateMetadata juntos

Además, searchParams —los query params de la URL— solo está disponible en page.tsx, no en layout.tsx. Esto significa que si tu metadata depende de ?q=algo, tiene que ir en la página, no en el layout.

Cuándo usar metadata estática vs dinámica

SituaciónUsar
Título y descripción son fijosexport const metadata
Título/descripción vienen de la DBgenerateMetadata()
Ruta estática (/about, /contact)export const metadata
Ruta dinámica (/blog/[slug], /products/[id])generateMetadata()
Metadata varía según searchParamsgenerateMetadata() en page.tsx
Imagen OG con contenido dinámicoopengraph-image.tsx
Imagen OG fija para todo el sitioCampo images en export const metadata

La regla práctica

Un patrón que funciona bien para la mayoría de los proyectos:

  1. metadataBase en el layout raíz — siempre, sin excepción. Sin esto las URLs de OG quedan rotas.
  2. title.template en el layout raíz — define el formato %s | Nombre del sitio. Todas las páginas heredan este formato.
  3. OG base en el layout raíz — imagen de fallback, siteName, locale. Las páginas hijas la sobreescriben cuando necesitan algo específico.
  4. generateMetadata en rutas dinámicas[slug], [id], cualquier ruta cuyo contenido cambia por ID.
  5. alternates.canonical siempre — en páginas que podrían tener URLs duplicadas (paginación, filtros, variantes de producto).
tsx
// app/layout.tsx — configuración base del sitio
export const metadata: Metadata = {
  metadataBase: new URL('https://acme.com'),
  title: {
    template: '%s | Acme',
    default: 'Acme',
  },
  description: 'Descripción del sitio',
  openGraph: {
    siteName: 'Acme',
    locale: 'es_AR',
    type: 'website',
    images: [{ url: '/og-default.png', width: 1200, height: 630 }],
  },
  twitter: {
    card: 'summary_large_image',
    site: '@acme',
  },
}

A partir de ahí, cada página solo necesita sobreescribir lo que es específico de ese contexto.

Errores comunes

Usar metadata en un Client Component. Si agregás 'use client' a un archivo que también exporta metadata, Next.js tira un error en build time. La solución es separar: el componente interactivo va en su propio archivo con 'use client', y la metadata queda en page.tsx o layout.tsx sin esa directiva.

Olvidarse de metadataBase. Sin esta configuración, las URLs relativas en los campos de OG (/og.png) quedan como rutas relativas inválidas en los meta tags. Los scrapers de redes sociales no pueden resolver /og.png sin saber el dominio base.

Imágenes OG con dimensiones incorrectas. Facebook y LinkedIn recortan o ignoran imágenes que no tienen la proporción 1.91:1. Si tu imagen OG es cuadrada o tiene otro aspecto, la preview en redes puede verse mal o no mostrarse. El tamaño recomendado es 1200×630px.

Canonical duplicado o ausente. En páginas con paginación (/blog?page=2), filtros (/products?color=rojo), o variantes de producto, cada URL diferente puede indexarse como contenido duplicado. Definir alternates.canonical en generateMetadata le dice a Google cuál es la URL canónica.

No definir OG en páginas importantes. Si no definís openGraph.images, las redes sociales van a usar cualquier imagen que encuentren en la página, o no mostrar ninguna. Páginas de landing, artículos populares, y páginas de producto deberían tener siempre una imagen OG explícita.

Emanuel López

Emanuel López

Desarrollador de Software · Montevideo, Uruguay

Mantente al día

Te aviso cuando publique algo nuevo. Podés darte de baja cuando quieras.