Guía completa de SEO en Next.js: Metadata API y datos estructurados en la práctica

El panel de estadísticas de Google Search Console muestra un gran «0»: día 23 desde el lanzamiento. Meses de desarrollo, noches sin dormir, UI pulida, experiencia fluida, y todo queda ante ese «0» helado.
Peor aún: al compartir el enlace en Twitter, la vista previa queda en blanco. Ni siquiera una imagen de portada decente.
«¿Next.js no trae SSR de serie? ¿Por qué el SEO sigue tan mal?» Revisé la documentación oficial y entendí algo duro: SSR no equivale a SEO-friendly. Si las meta tags están mal, faltan datos estructurados o no dominas Open Graph, los buscadores te ignoran igual.
Seguro que tú también lo has vivido: un producto hecho con esfuerzo que no aparece en búsqueda, que al compartirlo no se ve profesional y solo puede crecer con publicidad de pago. Pero con Metadata API de Next.js 15 y unas configuraciones clave, estos problemas tienen solución.
En este artículo te enseño paso a paso cómo usar Metadata API para que cada página tenga meta tags únicas, cómo configurar datos estructurados para destacar en resultados y cómo lograr vistas previas sociales impecables. Y, sobre todo, los 5 errores SEO más comunes para no repetir los que yo cometí.
¿Por qué el SEO de tu sitio Next.js es tan malo?
SSR no equivale a SEO-friendly
Sinceramente, yo también pensaba así al principio. Next.js, render en servidor, HTML directo para los crawlers: SEO perfecto, ¿no?
Ingenuo.
Luego revisé el código de un proyecto de un amigo. Abrí las DevTools y miré el HTML: en todas las páginas el <title> era «My App» y el <meta name="description"> faltaba o era idéntico. Es como abrir una tienda boutique con un cartel que solo dice «Tienda»: el cliente no sabe qué vendes.
Next.js sí ofrece SSR, pero configurar las meta tags es tu responsabilidad. Si no lo haces, sirves HTML vacío y los buscadores no saben de qué trata cada página.
5 errores SEO mortales
He visto a demasiados desarrolladores caer en estos fallos, yo incluido:
1. Todas las páginas comparten el mismo title y description
El error más habitual. O un title fijo en _document.tsx, o directamente ninguno. Resultado: Google indexa inicio, about y producto con el mismo título y descripción.
Imagina una librería donde todos los libros llevan el mismo título en la portada. ¿Comprarías alguno?
2. Olvidar la URL canónica y provocar contenido duplicado
Esta trampa es muy sutil. Tu sitio puede tener paginación (?page=2), filtros (?category=tech), orden (?sort=date): vistas distintas de la misma página que los buscadores tratan como URLs diferentes y penalizan como «contenido duplicado».
Un blog de un cliente perdió el 40 % del tráfico por esto. Tras añadir canonical URL, se recuperó en dos semanas.
3. Sin datos estructurados, pierdes rich snippets
¿Has buscado «receta de tarta de manzana»? Algunos resultados muestran valoración, tiempo de cocción y calorías. No es que Google lo adivine: el sitio se lo dice con datos estructurados (JSON-LD).
Según estudios, los sitios con datos estructurados mejoran el CTR un 20-30 % de media. Es como subir el tráfico un tercio con unas líneas de código.
4. Imágenes sin alt, desperdiciando tráfico de búsqueda de imágenes
Muchos creen que alt es solo para accesibilidad y no afecta al SEO. Error.
Google Imágenes es una fuente enorme de tráfico. Con un alt claro, puedes posicionarte ahí. Conozco un sitio de recursos de diseño donde el 30 % del tráfico viene de Google Imágenes por haber escrito bien el alt de cada imagen.
5. Sin sitemap.xml ni robots.txt
Estos archivos le dicen a los buscadores qué páginas pueden rastrear y cuáles no. Sin sitemap, Google puede tardar meses en descubrir un artículo nuevo. Con sitemap enviado a Search Console, la indexación puede tardar solo días.
Además, App Router de Next.js genera sitemap.ts y robots.ts automáticamente. ¿Por qué no configurarlos?
Dominar Metadata API (Next.js 15)
La Metadata API de Next.js 15 es uno de los mejores regalos del framework para SEO. Antes escribías <Head> en cada página; ahora exportas un objeto o función y Next.js se encarga.
Metadata estática: páginas con contenido fijo
El caso más simple: «Sobre nosotros», política de privacidad. Exporta metadata en page.tsx:
// app/about/page.tsx
import { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Sobre nosotros - TechBlog',
description: 'Somos desarrolladores apasionados por la tecnología; compartimos experiencia en frontend, backend y DevOps.',
keywords: ['blog técnico', 'desarrollo frontend', 'Next.js', 'React'],
authors: [{ name: 'Zhang San' }],
openGraph: {
title: 'Sobre nosotros - TechBlog',
description: 'Blog técnico con experiencia real de desarrollo',
url: 'https://yourdomain.com/about',
siteName: 'TechBlog',
images: [
{
url: 'https://yourdomain.com/og-about.jpg',
width: 1200,
height: 630,
}
],
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'Sobre nosotros - TechBlog',
description: 'Blog técnico con experiencia real de desarrollo',
images: ['https://yourdomain.com/og-about.jpg'],
},
}
export default function AboutPage() {
return <div>Contenido de sobre nosotros...</div>
}
Type-safe, autocompletado en el IDE, sin typos. Next.js deduplica meta tags duplicadas y las fusiona de forma inteligente.
Buenas prácticas:
title: máximo 60 caracteres; el resto se trunca en resultadosdescription: 150-160 caracteres, longitud ideal para el snippet de GoogleopenGraph.images: 1200x630 píxeles, tamaño universal en Twitter, Facebook y LinkedIn
Metadata dinámica: salvación para blog y productos
Lo potente es generateMetadata. En un artículo de blog, cada post tiene título y descripción distintos; no puedes escribirlos a mano.
// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
import { getPostBySlug } from '@/lib/posts'
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await getPostBySlug(params.slug)
return {
title: `${post.title} - TechBlog`,
description: post.excerpt,
authors: [{ name: post.author }],
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
type: 'article',
publishedTime: post.publishedAt,
authors: [post.author],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
}
}
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug)
return <article>{post.content}</article>
}
Cada artículo tiene su SEO. Google recibe HTML completo sin depender de JavaScript en el cliente.
Truco clave: metadataBase
¿Notaste que las URLs de imagen son absolutas? Si usas rutas relativas (/images/cover.jpg), configura metadataBase en el root layout:
// app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL('https://yourdomain.com'),
}
Así las rutas relativas se convierten en URL completas. Sin esto, Open Graph falla al rastrear y el share social queda sin imagen.
Plantillas: formato unificado de títulos
Muchos sitios usan «Página | Nombre del sitio». Con title.template no repites el sufijo:
// app/layout.tsx (root layout)
export const metadata: Metadata = {
title: {
template: '%s | TechBlog',
default: 'TechBlog - Blog técnico',
},
description: 'Blog técnico: frontend, backend y DevOps en la práctica',
metadataBase: new URL('https://yourdomain.com'),
}
En páginas hijas solo pones el nombre:
// app/about/page.tsx
export const metadata: Metadata = {
title: 'Sobre nosotros', // se renderiza como "Sobre nosotros | TechBlog"
}
¿La home sin sufijo? Usa title.absolute:
// app/page.tsx
export const metadata: Metadata = {
title: {
absolute: 'TechBlog - Inicio del blog técnico', // no usa template
},
}
Mantienes consistencia y un solo cambio en el root layout actualiza todo.
Datos estructurados (Schema.org) para destacar
¿Qué son y por qué importan?
¿Has buscado «cómo hacer tarta de manzana»?
Algunos resultados muestran valoración (4,8 estrellas), tiempo (45 min) y calorías (320 kcal), incluso pasos. La diferencia son los datos estructurados.
Es un formato estándar (JSON-LD) que le dice al buscador: «este es un artículo, autor X, fecha Y» o «este es un producto, precio Z, valoración W». Con eso obtienes rich snippets en los resultados.
Los datos no mienten: sitios con datos estructurados suelen mejorar el CTR un 20-30 %, con coste casi nulo.
Tipos Schema habituales
Schema.org define cientos de tipos; en la práctica suelen bastar:
- Organization — empresa u organización (home)
- BlogPosting — artículo de blog (cada post)
- Product — producto (e-commerce: precio, valoración, stock)
- FAQPage — preguntas frecuentes (se expanden en resultados)
- LocalBusiness — negocio local (dirección, horario, teléfono)
Aquí nos centramos en los dos primeros, los más útiles para blogs y sitios corporativos.
Implementar JSON-LD en Next.js
El componente <Script> de Next.js 15 simplifica mucho. Suele crearse un componente reutilizable:
// components/StructuredData.tsx
import Script from 'next/script'
type StructuredDataProps = {
data: object
}
export default function StructuredData({ data }: StructuredDataProps) {
return (
<Script
id="structured-data"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
)
}
Uso en página:
Ejemplo 1: Organization (empresa)
// app/layout.tsx (root layout)
import StructuredData from '@/components/StructuredData'
const organizationData = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'TechBlog',
url: 'https://yourdomain.com',
logo: 'https://yourdomain.com/logo.png',
sameAs: [
'https://twitter.com/yourusername',
'https://github.com/yourcompany',
'https://linkedin.com/company/yourcompany',
],
contactPoint: {
'@type': 'ContactPoint',
email: '[email protected]',
contactType: 'Customer Service',
},
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
<StructuredData data={organizationData} />
</body>
</html>
)
}
Ejemplo 2: BlogPosting (artículo)
// app/blog/[slug]/page.tsx
import StructuredData from '@/components/StructuredData'
import { getPostBySlug } from '@/lib/posts'
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug)
const articleData = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
image: post.coverImage,
author: {
'@type': 'Person',
name: post.author,
url: `https://yourdomain.com/author/${post.authorSlug}`,
},
publisher: {
'@type': 'Organization',
name: 'TechBlog',
logo: {
'@type': 'ImageObject',
url: 'https://yourdomain.com/logo.png',
},
},
datePublished: post.publishedAt,
dateModified: post.updatedAt,
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `https://yourdomain.com/blog/${post.slug}`,
},
}
return (
<>
<article>{post.content}</article>
<StructuredData data={articleData} />
</>
)
}
Parece mucho código, pero es meter los metadatos del artículo en formato estándar. Configuras una vez y luego copias el patrón.
Validar datos estructurados
Después de configurar, comprueba con:
-
Google Rich Results Test (https://search.google.com/test/rich-results)
- Introduce la URL; Google indica qué rich results puedes mostrar
- Los errores se señalan con precisión
-
Schema Markup Validator (https://validator.schema.org/)
- Comprueba que el JSON-LD cumple Schema.org
- Más estricto que Google; conviene usar ambos
Errores frecuentes: olvidar publisher en BlogPosting (obligatorio) o URL de imagen no absoluta. Las herramientas dicen qué falta; corriges y vuelves a probar.
Open Graph y Twitter Cards en la práctica
¿Por qué importa el share social?
¿Te ha pasado compartir en Twitter o Facebook y que la vista previa quede vacía o con una imagen incorrecta (logo o decoración aleatoria)?
Parece poco profesional. Los sitios bien configurados muestran portada, título y descripción; el CTR puede ser 2-3 veces mayor.
Open Graph y Twitter Cards controlan cómo se ve tu enlace en redes.
Open Graph en detalle
Open Graph nació en Facebook; hoy lo usan Twitter, LinkedIn, Slack, Discord, etc.
En Metadata API ya vimos openGraph; repasemos los campos clave:
export const metadata: Metadata = {
openGraph: {
// Obligatorios
title: 'Título del artículo',
description: 'Resumen, unos 150 caracteres',
url: 'https://yourdomain.com/article',
siteName: 'TechBlog',
// Imagen (¡lo más importante!)
images: [
{
url: 'https://yourdomain.com/og-image.jpg',
width: 1200,
height: 630,
alt: 'Descripción de la imagen (accesibilidad y SEO)',
},
],
type: 'article',
publishedTime: '2025-01-15T08:00:00.000Z',
modifiedTime: '2025-01-16T10:30:00.000Z',
authors: ['Zhang San', 'Li Si'],
tags: ['Next.js', 'SEO', 'desarrollo frontend'],
locale: 'es_ES',
alternateLocale: ['en_US', 'ja_JP'],
},
}
Tamaño de imagen
1200x630 (ratio 1,91:1) funciona bien en todas las plataformas:
- Facebook, LinkedIn: visualización completa
- Twitter: recorte 2:1 sin verse mal
- Slack, Discord: igual
El archivo no debe superar 8 MB o el build puede fallar.
Twitter Cards
Twitter tiene sus propias meta tags; aunque puede usar Open Graph, conviene configurarlas aparte:
export const metadata: Metadata = {
twitter: {
card: 'summary_large_image',
site: '@yourusername',
creator: '@authorusername',
title: 'Título del artículo',
description: 'Resumen del artículo',
images: ['https://yourdomain.com/twitter-image.jpg'],
},
}
Valores de card:
summary: imagen pequeña a la izquierdasummary_large_image: imagen grande arriba (recomendado)
Límite de imagen en Twitter: 5 MB (más estricto que OG).
Generar imágenes OG dinámicamente (avanzado)
¿Diseñar 1200x630 para cada artículo a mano? Agotador.
Desde Next.js 13.3 puedes generar OG con código, ideal para blogs:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPostBySlug } from '@/lib/posts'
export const runtime = 'edge'
export const alt = 'Portada del artículo'
export const size = {
width: 1200,
height: 630,
}
export const contentType = 'image/png'
export default async function Image({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug)
return new ImageResponse(
(
<div
style={{
fontSize: 60,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
padding: '80px',
}}
>
<h1 style={{ fontSize: 72, fontWeight: 'bold', textAlign: 'center' }}>
{post.title}
</h1>
<p style={{ fontSize: 36, marginTop: 20, opacity: 0.9 }}>
by {post.author}
</p>
</div>
),
{
...size,
}
)
}
Cada artículo obtiene su imagen de share según el título, sin diseño manual.
Para fuentes o fondos personalizados, consulta la documentación de next/og.
Probar el share social
Antes de publicar, valida con:
-
Facebook Sharing Debugger (https://developers.facebook.com/tools/debug/)
- Muestra cómo Facebook rastrea la URL
- Facebook cachea OG: tras cambios, pulsa «Scrape Again»
-
Twitter Card Validator (https://cards-dev.twitter.com/validator)
- Vista previa de la card (desde 2023 suele requerir cuenta de desarrollador; también puedes probar publicando un tweet)
-
LinkedIn Post Inspector (https://www.linkedin.com/post-inspector/)
- Vista previa y refresco de caché en LinkedIn
Trampa habitual: cambias la imagen OG y Facebook sigue mostrando la antigua. Usa Sharing Debugger para invalidar caché.
Otras configuraciones SEO imprescindibles
Metadata API, datos estructurados y Open Graph son el núcleo, pero no olvides lo siguiente.
sitemap.xml — qué páginas tiene tu sitio
El sitemap es XML con las URL del sitio. Google y Bing lo leen para indexar más rápido.
En App Router basta con app/sitemap.ts:
// app/sitemap.ts
import { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/posts'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
const baseUrl = 'https://yourdomain.com'
const staticPages: MetadataRoute.Sitemap = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
{
url: `${baseUrl}/about`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
]
const blogPages: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'weekly' as const,
priority: 0.7,
}))
return [...staticPages, ...blogPages]
}
Next.js sirve esto en https://yourdomain.com/sitemap.xml.
Después de configurar:
- Envía a Google Search Console (https://search.google.com/search-console)
- Envía a Bing Webmaster Tools (https://www.bing.com/webmasters)
La indexación de páginas nuevas puede mejorar más del 50 %. Sin sitemap, un artículo podía tardar dos semanas; con envío, a veces tres días.
robots.txt — permisos de rastreo
robots.txt indica qué puede rastrear cada crawler.
// app/robots.ts
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/admin', '/api', '/private'],
},
],
sitemap: 'https://yourdomain.com/sitemap.xml',
}
}
Genera https://yourdomain.com/robots.txt similar a:
User-agent: *
Allow: /
Disallow: /admin
Disallow: /api
Disallow: /private
Sitemap: https://yourdomain.com/sitemap.xml
Casos típicos:
/adminno debe indexarse/apitampoco tiene sentido rastrearlo- Borradores y previews:
noindexoDisallow
URL canónica — evitar duplicados
La canónica dice: «esta URL tiene variantes, pero esta es la oficial».
Escenarios:
- Paginación:
/blog?page=1,/blog?page=2 - Filtros:
/products?category=tech - Orden:
/products?sort=price
Sin canónica, el buscador dispersa autoridad por «duplicados».
En Next.js:
// app/blog/page.tsx
export const metadata: Metadata = {
alternates: {
canonical: 'https://yourdomain.com/blog',
},
}
O dinámico:
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
return {
alternates: {
canonical: `https://yourdomain.com/blog/${params.slug}`,
},
}
}
Con varios idiomas, alternates.languages:
export const metadata: Metadata = {
alternates: {
canonical: 'https://yourdomain.com/blog/nextjs-seo',
languages: {
'en-US': 'https://yourdomain.com/en/blog/nextjs-seo',
'ja-JP': 'https://yourdomain.com/ja/blog/nextjs-seo',
},
},
}
Optimización de imágenes — next/image + alt
Muchos ignoran el SEO de imágenes; Google Imágenes es tráfico real.
Dos puntos clave:
next/imageen lugar de<img alt="">
import Image from 'next/image'
<Image
src="/cover.jpg"
alt="Portada de la guía completa de SEO en Next.js"
width={1200}
height={630}
priority
/>
next/image aporta:
- Lazy load (fuera del viewport)
- WebP automático
- Tamaños responsive
- Menos CLS (Core Web Vitals)
altobligatorio
No es opcional: SEO y accesibilidad (lectores de pantalla).
Buen alt:
- ✅ «Ejemplo de código de Metadata API en Next.js»
- ✅ «Rich snippet de artículo en resultados de Google»
Mal alt:
- ❌ «imagen»
- ❌ «screenshot.png»
- ❌ sin alt
Google Imágenes posiciona según alt. Un sitio de assets de diseño con el 30 % del tráfico desde imágenes lo consiguió escribiendo alt con cuidado.
Caso práctico — SEO completo en un blog
Integramos todo en un blog con Next.js 15 App Router.
Estructura del proyecto
app/
├── layout.tsx # Root layout — configuración global
├── page.tsx # Inicio
├── about/page.tsx # Sobre nosotros
├── blog/
│ ├── page.tsx # Listado del blog
│ └── [slug]/
│ ├── page.tsx # Detalle del artículo
│ └── opengraph-image.tsx # OG dinámica (opcional)
├── sitemap.ts # Generación de sitemap
└── robots.ts # Generación de robots.txt
Código completo
1. Root Layout — SEO global
// app/layout.tsx
import { Metadata } from 'next'
import StructuredData from '@/components/StructuredData'
export const metadata: Metadata = {
metadataBase: new URL('https://yourdomain.com'),
title: {
template: '%s | TechBlog',
default: 'TechBlog - Blog de desarrollo frontend',
},
description: 'Blog técnico con experiencia real en Next.js, React y TypeScript',
keywords: ['Next.js', 'React', 'TypeScript', 'desarrollo frontend', 'blog técnico'],
authors: [{ name: 'Zhang San', url: 'https://yourdomain.com/about' }],
openGraph: {
type: 'website',
siteName: 'TechBlog',
locale: 'es_ES',
},
twitter: {
card: 'summary_large_image',
site: '@yourusername',
},
}
const organizationData = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'TechBlog',
url: 'https://yourdomain.com',
logo: 'https://yourdomain.com/logo.png',
sameAs: [
'https://twitter.com/yourusername',
'https://github.com/yourcompany',
],
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es">
<body>
{children}
<StructuredData data={organizationData} />
</body>
</html>
)
}
2. Inicio — metadata estática
// app/page.tsx
import { Metadata } from 'next'
export const metadata: Metadata = {
title: {
absolute: 'TechBlog - Blog de desarrollo frontend',
},
description: 'Experiencia práctica en Next.js, React y TypeScript para mejorar tus habilidades',
openGraph: {
title: 'TechBlog - Blog de desarrollo frontend',
description: 'Experiencia real en desarrollo frontend',
url: 'https://yourdomain.com',
images: [
{
url: 'https://yourdomain.com/og-home.jpg',
width: 1200,
height: 630,
alt: 'Portada de inicio de TechBlog',
},
],
},
}
export default function HomePage() {
return <div>Contenido de inicio...</div>
}
3. Detalle del blog — metadata dinámica + datos estructurados
// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPostBySlug } from '@/lib/posts'
import StructuredData from '@/components/StructuredData'
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await getPostBySlug(params.slug)
if (!post) return {}
return {
title: post.title,
description: post.excerpt,
keywords: post.tags,
authors: [{ name: post.author }],
openGraph: {
title: post.title,
description: post.excerpt,
url: `https://yourdomain.com/blog/${post.slug}`,
images: [post.coverImage],
type: 'article',
publishedTime: post.publishedAt,
authors: [post.author],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
alternates: {
canonical: `https://yourdomain.com/blog/${post.slug}`,
},
}
}
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug)
if (!post) notFound()
const articleData = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
image: post.coverImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt || post.publishedAt,
author: {
'@type': 'Person',
name: post.author,
},
publisher: {
'@type': 'Organization',
name: 'TechBlog',
logo: {
'@type': 'ImageObject',
url: 'https://yourdomain.com/logo.png',
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `https://yourdomain.com/blog/${post.slug}`,
},
}
return (
<>
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
<StructuredData data={articleData} />
</>
)
}
4. Sitemap y Robots
// app/sitemap.ts
import { getAllPosts } from '@/lib/posts'
export default async function sitemap() {
const posts = await getAllPosts()
const baseUrl = 'https://yourdomain.com'
const blogUrls = posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'weekly' as const,
priority: 0.7,
}))
return [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 1,
},
{
url: `${baseUrl}/about`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.8,
},
...blogUrls,
]
}
// app/robots.ts
export default function robots() {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/api', '/admin'],
},
sitemap: 'https://yourdomain.com/sitemap.xml',
}
}
Checklist tras el despliegue
Antes de dar por cerrado:
-
Ver código HTML
- F12 → Elements → revisar
<head> - Confirmar
<title>, description y etiquetas OG
- F12 → Elements → revisar
-
Probar sitemap y robots
https://yourdomain.com/sitemap.xmlhttps://yourdomain.com/robots.txt
-
Validar datos estructurados
- Google Rich Results Test en varias páginas
- Sin errores ni avisos críticos
-
Probar share social
- Facebook Sharing Debugger y Twitter Card Validator
- Imagen, título y descripción correctos
-
Enviar a buscadores
- Sitemap en Google Search Console
- Sitemap en Bing Webmaster Tools
Con esto, el SEO de tu sitio Next.js queda completo.
Conclusión
Si llegaste hasta aquí, ya sabes que SSR en Next.js no es SEO automático, pero las herramientas de Next.js 15 simplifican mucho la configuración.
Resumen:
- Metadata API: meta tags type-safe;
metadataen estáticas,generateMetadataen dinámicas - Datos estructurados (JSON-LD): pueden subir el CTR un 20-30 %;
<Script>lo hace sencillo - Open Graph y Twitter Cards: definen el share social; 1200x630 es el tamaño seguro
- sitemap.xml y robots.txt: con archivos
.tsse generan solos; envíalos a Search Console - Imágenes:
next/image+ alt cuidado; Google Imágenes también trae tráfico
Parece mucho trabajo, pero el retorno es alto. Muchos productos técnicos fuertes se quedan sin tráfico orgánico por SEO mal hecho; los bien configurados crecen con coste casi cero.
El SEO no es magia, es método. Sigue esta lista, valida con herramientas y en uno o tres meses verás el cambio.
No esperes a quedarte sin tráfico. Abre tu proyecto y dedica medio día a configurarlo. Guarda este artículo para consultarlo cuando haga falta.
Si te sirvió, compártelo con otros desarrolladores: les ahorrarás varios tropiezos.
Flujo completo de optimización SEO en Next.js
Pasos completos de optimización SEO: desde Metadata API hasta datos estructurados, sitemap y robots.txt
⏱️ Estimated time: 4 hr
- 1
Step 1: Configurar metadata básica
Usa la Metadata API de Next.js 15:
• Exporta el objeto metadata en layout.js o page.js
• Configura title, description y keywords
• Define Open Graph y Twitter Cards
Ejemplo:
export const metadata = {
title: 'Título de la página',
description: 'Descripción de la página',
openGraph: {
title: 'Título OG',
description: 'Descripción OG',
images: ['/og-image.jpg']
}
} - 2
Step 2: Configurar metadata dinámica
Para rutas dinámicas:
• Usa la función generateMetadata
• Genera metadata según los parámetros de la ruta
• Admite funciones async para obtener datos
Ejemplo:
export async function generateMetadata({ params }) {
const post = await getPost(params.id)
return {
title: post.title,
description: post.description
}
} - 3
Step 3: Añadir datos estructurados
Usa el formato JSON-LD:
• Etiqueta script con type application/ld+json y contenido JSON (envuélvelo con la etiqueta en la implementación)
• Admite tipos Article, Product, FAQ, etc.
• Usa el vocabulario de Schema.org
Ejemplo (cuerpo JSON; en la página, JSON.stringify del objeto dentro del script):
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "Título del artículo"
}
En Next.js puedes emitir el JSON con next/script u otras formas. - 4
Step 4: Configurar sitemap y robots.txt
Crea sitemap.ts:
• Exporta una función default que devuelva un array de sitemap
• Incluye URL, lastModified y changeFrequency de cada página
• Admite generación dinámica
Crea robots.ts:
• Configura crawlers permitidos y bloqueados
• Define la ruta del sitemap
• Establece reglas de rastreo - 5
Step 5: Optimizar SEO de imágenes
Puntos clave de optimización de imágenes:
• Usa el componente next/image
• Añade alt con significado
• Configura dimensiones (1200x630 para imágenes OG)
• Usa formatos WebP/AVIF
• Añade datos estructurados de imagen (ImageObject) - 6
Step 6: Validar y probar
Herramientas de validación:
• Google Rich Results Test: valida datos estructurados
• Facebook Sharing Debugger: prueba etiquetas OG
• Twitter Card Validator: prueba Twitter Cards
• Google Search Console: envía sitemap y monitoriza
Checklist:
• Cada página con title y description únicos
• Imagen OG con tamaño correcto (1200x630)
• Datos estructurados con formato correcto
• Sitemap enviado a Search Console
FAQ
¿Qué relación hay entre SSR y SEO?
¿Cuál es la diferencia entre Metadata API y el componente Head?
¿Cómo configurar metadata para rutas dinámicas?
Ejemplo:
export async function generateMetadata({ params }) {
const data = await getData(params.id)
return { title: data.title }
}
¿Qué tamaño deben tener las imágenes Open Graph?
¿Son obligatorios los datos estructurados?
¿Hay que crear sitemap y robots.txt manualmente?
¿Cuánto tarda en verse el efecto de la optimización SEO?
Recomendaciones:
1) Enviar sitemap a Google Search Console
2) Validar con Google Rich Results Test
3) Monitorizar datos de Search Console
4) Mantener el contenido actualizado
16 min de lectura · Publicado el: 19 dic 2025 · Actualizado el: 21 ago 2026
Guía completa de Next.js
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Next.js Core Web Vitals en la práctica: guía completa de optimización LCP/FCP/CLS
Guía completa para optimizar LCP, FCP y CLS en Next.js y subir la puntuación de Lighthouse por encima de 90. Incluye más de 10 ejemplos de código, trampas habituales y consejos aplicados en producción.
Parte 27 de 51
Siguiente
Guía de configuración de Sitemap y robots.txt en Next.js: que los buscadores indexen tu sitio rápido
Guía completa para configurar Sitemap y robots.txt en Next.js: tres métodos de generación, errores habituales que debes evitar e integración con Google Search Console para que los sitios nuevos se indexen antes.
Parte 29 de 51



Comentarios
Inicia sesión con GitHub para dejar un comentario