Cambiar tema

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

Easton editorial illustration: hydration gauge console

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 resultados
  • description: 150-160 caracteres, longitud ideal para el snippet de Google
  • openGraph.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:

  1. Organization — empresa u organización (home)
  2. BlogPosting — artículo de blog (cada post)
  3. Product — producto (e-commerce: precio, valoración, stock)
  4. FAQPage — preguntas frecuentes (se expanden en resultados)
  5. 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:

  1. 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
  2. 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 izquierda
  • summary_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:

  1. Facebook Sharing Debugger (https://developers.facebook.com/tools/debug/)

    • Muestra cómo Facebook rastrea la URL
    • Facebook cachea OG: tras cambios, pulsa «Scrape Again»
  2. 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)
  3. 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:

  1. Envía a Google Search Console (https://search.google.com/search-console)
  2. 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:

  • /admin no debe indexarse
  • /api tampoco tiene sentido rastrearlo
  • Borradores y previews: noindex o Disallow

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:

  1. next/image en 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)
  1. alt obligatorio

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:

  1. Ver código HTML

    • F12 → Elements → revisar <head>
    • Confirmar <title>, description y etiquetas OG
  2. Probar sitemap y robots

    • https://yourdomain.com/sitemap.xml
    • https://yourdomain.com/robots.txt
  3. Validar datos estructurados

    • Google Rich Results Test en varias páginas
    • Sin errores ni avisos críticos
  4. Probar share social

    • Facebook Sharing Debugger y Twitter Card Validator
    • Imagen, título y descripción correctos
  5. 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; metadata en estáticas, generateMetadata en 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 .ts se 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. 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. 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. 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. 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. 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. 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?
SSR (Server-Side Rendering) solo genera HTML en el servidor, pero el SEO también requiere configurar correctamente meta tags, datos estructurados, Open Graph, etc. SSR no equivale a SEO-friendly: debes configurar activamente Metadata API para que los buscadores entiendan e indexen el contenido.
¿Cuál es la diferencia entre Metadata API y el componente Head?
Metadata API es el enfoque recomendado en Next.js 15: type-safe, admite metadata estática y dinámica y gestiona automáticamente etiquetas duplicadas. Head es el enfoque de React, requiere gestión manual y es propenso a errores. En proyectos nuevos, usa Metadata API.
¿Cómo configurar metadata para rutas dinámicas?
Usa la función generateMetadata, recibe params, puede obtener datos de forma async y devolver el objeto metadata.

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?
El tamaño recomendado es 1200x630 píxeles, el tamaño universal admitido por Facebook, Twitter, LinkedIn y otras plataformas. El archivo de imagen conviene mantenerlo por debajo de 1 MB, en formato JPEG o PNG.
¿Son obligatorios los datos estructurados?
No son obligatorios, pero se recomiendan mucho. Los datos estructurados permiten rich results en búsqueda (valoraciones, precios, FAQ, etc.) y mejoran el CTR. Google, Bing y otros buscadores admiten datos estructurados en formato JSON-LD.
¿Hay que crear sitemap y robots.txt manualmente?
No. Next.js permite crear archivos sitemap.ts y robots.ts que generan automáticamente sitemap.xml y robots.txt. sitemap.ts puede generar dinámicamente las URL de todas las páginas y robots.ts configura las reglas de rastreo.
¿Cuánto tarda en verse el efecto de la optimización SEO?
Suele tardar entre 1 y 3 meses. Los buscadores necesitan tiempo para rastrear e indexar contenido nuevo.

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

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog