Tema
Metadata y SEO
Una de las razones principales para elegir Next.js es el SEO: que Google encuentre y muestre bien las páginas de tu cliente, y que los enlaces se vean atractivos al compartirlos en WhatsApp, Facebook o LinkedIn. Aprenderás la Metadata API (estática y dinámica), Open Graph, imágenes OG generadas con código, sitemap.xml, robots.txt y datos estructurados.
Concepto
Los buscadores y las redes sociales leen etiquetas del head del HTML:
html
<title>Café de Chiapas 500 g | Tiendita</title>
<meta name="description" content="Café 100% de altura, tueste medio..." />
<link rel="canonical" href="https://tiendita.com/productos/cafe-chiapas" />
<meta property="og:title" content="Café de Chiapas 500 g" />
<meta property="og:image" content="https://tiendita.com/og/cafe.png" />1
2
3
4
5
2
3
4
5
En Next.js no escribes estas etiquetas a mano: exportas un objeto metadata (o una función generateMetadata) desde un layout.tsx o page.tsx y Next genera el HTML. Como se renderiza en el servidor, los bots ven todo desde el primer momento.
Metadata estática
tsx
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
// URL base: convierte rutas relativas (imágenes OG, canonical) en absolutas
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'),
title: {
default: 'Tiendita — Café y accesorios mexicanos',
template: '%s | Tiendita' // las páginas hijas sólo definen su parte
},
description: 'Tienda en línea de café de especialidad mexicano, métodos de preparación y accesorios.',
keywords: ['café mexicano', 'café de Chiapas', 'tienda de café'],
authors: [{ name: 'Tiendita' }],
openGraph: {
type: 'website',
locale: 'es_MX',
siteName: 'Tiendita',
images: ['/og-default.png'] // 1200×630 en /public
},
twitter: { card: 'summary_large_image' },
icons: { icon: '/favicon.ico', apple: '/apple-touch-icon.png' },
alternates: { canonical: '/' }
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es-MX">
<body>{children}</body>
</html>
)
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
tsx
// app/nosotros/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Nosotros', // → "Nosotros | Tiendita"
description: 'Conoce la historia de Tiendita y a los productores con los que trabajamos.',
alternates: { canonical: '/nosotros' }
}
export default function Nosotros() {
return <h1>Nosotros</h1>
}1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
La metadata se combina de la raíz hacia las páginas: cada nivel puede sobrescribir lo que necesite.
title.absolute
Si una página no debe usar la plantilla (por ejemplo, la portada), usa title: { absolute: 'Tiendita — El mejor café mexicano' }.
Metadata dinámica: generateMetadata
Para páginas que dependen de datos (productos, posts):
tsx
// app/productos/[slug]/page.tsx
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { cache } from 'react'
type Producto = { slug: string; nombre: string; descripcion: string; precio: number; imagen: string; stock: number }
// cache: la página y generateMetadata comparten una sola consulta por petición
const obtenerProducto = cache(async (slug: string): Promise<Producto | null> => {
// Ejemplo con datos fijos; en la vida real: Prisma o una API
const productos: Producto[] = [
{
slug: 'cafe-chiapas',
nombre: 'Café de Chiapas 500 g',
descripcion: 'Café 100% de altura, tueste medio, notas de chocolate y nuez.',
precio: 249,
imagen: '/productos/cafe-chiapas.jpg',
stock: 12
}
]
return productos.find((p) => p.slug === slug) ?? null
})
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const p = await obtenerProducto(slug)
if (!p) return { title: 'Producto no encontrado', robots: { index: false } }
return {
title: p.nombre,
description: p.descripcion,
alternates: { canonical: `/productos/${p.slug}` },
openGraph: {
title: p.nombre,
description: p.descripcion,
images: [{ url: p.imagen, width: 1200, height: 630, alt: p.nombre }]
}
}
}
export default async function PaginaProducto({ params }: Props) {
const { slug } = await params
const p = await obtenerProducto(slug)
if (!p) notFound()
// Datos estructurados (JSON-LD): Google puede mostrar precio y disponibilidad
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: p.nombre,
description: p.descripcion,
image: p.imagen,
offers: {
'@type': 'Offer',
priceCurrency: 'MXN',
price: p.precio,
availability: p.stock > 0 ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock'
}
}
return (
<main className="mx-auto max-w-3xl p-8">
<script
type="application/ld+json"
// JSON.stringify + reemplazo de "<" evita inyección de HTML
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c') }}
/>
<h1 className="text-3xl font-bold">{p.nombre}</h1>
<p className="mt-4">{p.descripcion}</p>
<p className="mt-4 text-2xl font-bold">${p.precio} MXN</p>
</main>
)
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
Puedes validar los datos estructurados con la Prueba de resultados enriquecidos de Google.
Imágenes Open Graph generadas con código
En lugar de diseñar una imagen para cada producto o post, Next.js puede generarlas con JSX. Crea un archivo opengraph-image.tsx en la carpeta de la ruta:
tsx
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { obtenerPost } from '@/lib/posts'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export const alt = 'Artículo del blog'
export default async function Imagen({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await obtenerPost(slug)
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: 72,
background: 'linear-gradient(135deg, #4c1d95, #7c3aed)',
color: 'white'
}}
>
<div style={{ fontSize: 32, opacity: 0.8 }}>Blog · MiSitio</div>
<div style={{ fontSize: 68, fontWeight: 800, lineHeight: 1.1 }}>{post?.titulo ?? 'Artículo'}</div>
<div style={{ fontSize: 28, opacity: 0.8 }}>{post?.resumen}</div>
</div>
),
size
)
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Next agrega automáticamente las etiquetas og:image correspondientes. ImageResponse usa un subconjunto de CSS (flexbox, sin grid): cada div con varios hijos necesita display: 'flex'.
Archivos especiales de metadata
Archivo en app/ | Genera |
|---|---|
favicon.ico, icon.png, apple-icon.png | Íconos |
opengraph-image.png / .tsx | Imagen al compartir |
twitter-image.png / .tsx | Imagen para X/Twitter |
sitemap.ts | /sitemap.xml |
robots.ts | /robots.txt |
manifest.ts | /manifest.webmanifest (PWA) |
sitemap.xml
El sitemap le dice a Google qué páginas existen y cuándo cambiaron:
ts
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { obtenerPosts } from '@/lib/posts'
const SITIO = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://misitio.com'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await obtenerPosts()
const estaticas: MetadataRoute.Sitemap = ['', '/nosotros', '/servicios', '/blog', '/contacto'].map((ruta) => ({
url: `${SITIO}${ruta}`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: ruta === '' ? 1 : 0.8
}))
const articulos: MetadataRoute.Sitemap = posts.map((p) => ({
url: `${SITIO}/blog/${p.slug}`,
lastModified: new Date(p.fecha),
changeFrequency: 'weekly',
priority: 0.6
}))
return [...estaticas, ...articulos]
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
robots.txt
ts
// app/robots.ts
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
const sitio = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://misitio.com'
return {
rules: [{ userAgent: '*', allow: '/', disallow: ['/admin', '/api', '/carrito'] }],
sitemap: `${sitio}/sitemap.xml`
}
}1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
No indexar páginas privadas
tsx
// app/admin/layout.tsx
export const metadata = {
robots: { index: false, follow: false }
}1
2
3
4
2
3
4
Checklist de SEO técnico
| ✅ | Qué revisar |
|---|---|
| Títulos | Únicos por página, 50–60 caracteres, palabra clave al inicio |
| Descripciones | Únicas, 140–160 caracteres, invitan al clic |
| Encabezados | Un solo h1 por página, jerarquía h2, h3 lógica |
| URLs | Legibles con slugs (/blog/como-elegir-cafe) |
| Canonical | Evita contenido duplicado (filtros, parámetros) |
| Imágenes | alt descriptivo, tamaño optimizado (lección 10) |
| Velocidad | Buenas Core Web Vitals (Lighthouse) |
| Sitemap y robots | Generados y enviados a Google Search Console |
| Datos estructurados | Producto, artículo, negocio local, FAQ |
| Idioma | lang="es-MX" en html y locale en Open Graph |
| Móvil | Diseño responsive |
Después de publicar, da de alta el sitio en Google Search Console y envía el sitemap.
Buena práctica
Define metadataBase en el layout raíz con la URL real de producción (desde una variable de entorno). Sin ella, las imágenes Open Graph pueden quedar con URLs relativas que las redes sociales no saben resolver.
Error común
Usar la misma descripción y título en todas las páginas. Google lo considera contenido duplicado y muestra resultados poco atractivos. Usa generateMetadata para las páginas dinámicas.
Ejercicios
- Configura la metadata raíz de un sitio para un consultorio dental en Puebla (título con plantilla, descripción, Open Graph en
es_MX) y la metadata de la página/servicios. - Agrega datos estructurados
Dentist(tipo deLocalBusiness) con nombre, dirección, teléfono y horario a la página de inicio.
Ver solución
tsx
// 1. app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://sonrisaperfecta.mx'),
title: { default: 'Sonrisa Perfecta — Dentista en Puebla', template: '%s | Sonrisa Perfecta' },
description: 'Consultorio dental en Puebla: limpiezas, ortodoncia, blanqueamiento e implantes. Agenda tu cita hoy.',
openGraph: { type: 'website', locale: 'es_MX', siteName: 'Sonrisa Perfecta', images: ['/og.png'] },
alternates: { canonical: '/' }
}
// app/servicios/page.tsx
export const metadata: Metadata = {
title: 'Servicios dentales',
description: 'Ortodoncia, blanqueamiento, implantes y limpiezas en Puebla con especialistas certificados.',
alternates: { canonical: '/servicios' }
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
tsx
// 2. app/page.tsx
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Dentist',
name: 'Sonrisa Perfecta',
url: 'https://sonrisaperfecta.mx',
telephone: '+52-222-123-4567',
address: {
'@type': 'PostalAddress',
streetAddress: 'Av. Juárez 1234',
addressLocality: 'Puebla',
addressRegion: 'Puebla',
postalCode: '72000',
addressCountry: 'MX'
},
openingHoursSpecification: [
{
'@type': 'OpeningHoursSpecification',
dayOfWeek: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
opens: '09:00',
closes: '19:00'
}
]
}
export default function Inicio() {
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c') }}
/>
<h1>Tu dentista de confianza en Puebla</h1>
</main>
)
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
Resumen
- Exporta
metadataogenerateMetadatadesde layouts y páginas; usatitle.templateymetadataBase. - Open Graph y Twitter controlan cómo se ven tus enlaces compartidos; genera imágenes con
opengraph-image.tsx. sitemap.tsyrobots.tsgeneran/sitemap.xmly/robots.txt.- Agrega datos estructurados JSON-LD y marca las páginas privadas con
robots: { index: false }.