Saltar al contenido principal
Volver al blog

SEO y metadata en Next.js App Router: guía definitiva

Ray MartínRay Martín
9 min de lectura
SEO y metadata en Next.js App Router: guía definitiva

La API de Metadata en Next.js App Router

El App Router de Next.js introduce una potente API de Metadata que te da control total sobre el SEO de tu aplicacion directamente desde tus componentes. A diferencia del antiguo enfoque con next/head, la nueva API se integra directamente con los Server Components, permitiendo la generacion de metadata tanto estatica como dinamica con seguridad de tipos.

Una metadata adecuada es la base de la optimizacion para motores de busqueda. Le indica a los buscadores de que tratan tus paginas, como deben indexarse y como deben aparecer en los resultados de busqueda. Sin metadata bien configurada, incluso el mejor contenido tendra dificultades para posicionarse.

Metadata estatica

La forma mas sencilla de agregar metadata es exportando un objeto metadata desde tu archivo de pagina o layout. Este enfoque funciona para paginas donde la metadata no depende de datos dinamicos como parametros de URL o contenido de base de datos.

typescript
// app/[locale]/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Ray Martin — Desarrollador Fullstack de Producto",
  description:
    "Portfolio y sitio de agencia de Ray Martin. Construyendo aplicaciones web modernas con Next.js, React y TypeScript.",
  keywords: ["Next.js", "React", "TypeScript", "desarrollador fullstack", "portfolio"],
  authors: [{ name: "Ray Martin", url: "https://raymartin.es" }],
  creator: "Ray Martin",
  publisher: "Ray Martin",
  metadataBase: new URL("https://raymartin.es"),
  alternates: {
    canonical: "/",
    languages: {
      "es": "/es",
      "en": "/en",
    },
  },
};

export default function HomePage() {
  return <main>...</main>;
}

La metadata estatica se evalua en tiempo de compilacion y se incrusta directamente en el HTML. Este es el enfoque mas eficiente ya que no se necesita ninguna computacion en tiempo de ejecucion.

Metadata dinamica con generateMetadata

Para paginas que dependen de datos dinamicos — como articulos de blog, paginas de productos o perfiles de usuario — utiliza la funcion generateMetadata. Esta funcion asincrona recibe los parametros de la pagina, permitiendote obtener datos y construir metadata de forma dinamica.

typescript
// app/[locale]/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPostBySlug } from "@/lib/blog";

interface PageProps {
  params: Promise<{ locale: string; slug: string }>;
}

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { locale, slug } = await params;
  const post = await getPostBySlug(slug, locale);

  if (!post) {
    return {
      title: "Articulo no encontrado",
    };
  }

  return {
    title: post.title,
    description: post.excerpt,
    authors: [{ name: post.author }],
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.publishedAt,
      modifiedTime: post.updatedAt,
      authors: [post.author],
      images: [
        {
          url: post.coverImage,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
    alternates: {
      canonical: `/${locale}/blog/${slug}`,
      languages: {
        "es": `/es/blog/${slug}`,
        "en": `/en/blog/${slug}`,
      },
    },
  };
}

export default async function BlogPostPage({ params }: PageProps) {
  const { locale, slug } = await params;
  const post = await getPostBySlug(slug, locale);
  return <article>...</article>;
}

Next.js deduplica automaticamente las llamadas fetch realizadas tanto en generateMetadata como en el componente de pagina. Esto significa que puedes llamar a getPostBySlug en ambos lugares sin hacer peticiones de red redundantes.

Etiquetas Open Graph

Las etiquetas Open Graph controlan como aparecen tus paginas cuando se comparten en redes sociales como Facebook, LinkedIn y aplicaciones de mensajeria. Una configuracion Open Graph bien implementada puede aumentar dramaticamente las tasas de clics desde compartidos sociales.

typescript
// app/[locale]/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  openGraph: {
    title: "Ray Martin — Desarrollador Fullstack de Producto",
    description: "Construyendo aplicaciones web modernas con Next.js y React.",
    url: "https://raymartin.es",
    siteName: "Ray Martin Portfolio",
    locale: "es_ES",
    alternateLocale: "en_US",
    type: "website",
    images: [
      {
        url: "https://raymartin.es/og-image.jpg",
        width: 1200,
        height: 630,
        alt: "Vista previa del Portfolio de Ray Martin",
        type: "image/jpeg",
      },
      {
        url: "https://raymartin.es/og-image-square.jpg",
        width: 600,
        height: 600,
        alt: "Logo de Ray Martin",
        type: "image/jpeg",
      },
    ],
  },
};

Mejores practicas para Open Graph:

  • Dimensiones de og:image: Usa 1200x630 pixeles para la imagen principal — es el tamano recomendado para la mayoria de plataformas sociales
  • Longitud de og:title: Manten los titulos por debajo de 60 caracteres para evitar truncamiento en las vistas previas sociales
  • Longitud de og:description: Apunta a 120-160 caracteres para una visualizacion optima
  • og:type: Usa "website" para paginas de inicio y "article" para articulos de blog
  • og:locale: Configura el locale correcto para tu idioma principal
  • alternateLocale: Lista todos los demas idiomas soportados

Imagenes Open Graph dinamicas

Next.js te permite generar imagenes Open Graph de forma dinamica utilizando la API ImageResponse. Esto te permite crear imagenes de vista previa social personalizadas con texto, estilos e incluso datos de tu contenido.

typescript
// app/[locale]/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";

export const runtime = "edge";
export const alt = "Imagen de portada del articulo";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function Image({
  params,
}: {
  params: { slug: string };
}) {
  const post = await fetch(
    `https://raymartin.es/api/posts/${params.slug}`
  ).then((res) => res.json());

  return new ImageResponse(
    (
      <div
        style={{
          display: "flex",
          flexDirection: "column",
          justifyContent: "center",
          alignItems: "flex-start",
          width: "100%",
          height: "100%",
          padding: "60px",
          background: "linear-gradient(135deg, #0c4a6e 0%, #082f49 100%)",
          color: "white",
          fontFamily: "Inter, sans-serif",
        }}
      >
        <div style={{ fontSize: 48, fontWeight: 700, marginBottom: 20 }}>
          {post.title}
        </div>
        <div style={{ fontSize: 24, opacity: 0.8 }}>
          {post.excerpt}
        </div>
        <div style={{ fontSize: 20, marginTop: "auto", opacity: 0.6 }}>
          raymartin.es
        </div>
      </div>
    ),
    { ...size }
  );
}

Metadata de Twitter Card

Twitter (X) utiliza su propio conjunto de meta tags para generar vistas previas de tarjetas cuando se comparten enlaces. Next.js soporta tanto tarjetas de tipo summary como summary_large_image.

typescript
// app/[locale]/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  twitter: {
    card: "summary_large_image",
    title: "Ray Martin — Desarrollador Fullstack de Producto",
    description: "Construyendo aplicaciones web modernas con Next.js y React.",
    site: "@raymartin_dev",
    creator: "@raymartin_dev",
    images: {
      url: "https://raymartin.es/twitter-card.jpg",
      alt: "Vista previa del Portfolio de Ray Martin",
      width: 1200,
      height: 630,
    },
  },
};

Consejo: Si no se proporcionan etiquetas de Twitter card, Twitter usara las etiquetas Open Graph como alternativa. Sin embargo, establecer explicitamente la metadata de Twitter te da mas control sobre como aparece tu contenido en la plataforma.

Datos estructurados JSON-LD

Los datos estructurados JSON-LD ayudan a los motores de busqueda a comprender el contenido y contexto de tus paginas. Permiten resultados enriquecidos en Google Search, incluyendo migas de pan, tarjetas de articulos, preguntas frecuentes y paneles de conocimiento de organizaciones.

Schema de Article

typescript
// app/[locale]/blog/[slug]/page.tsx
interface ArticleJsonLd {
  "@context": "https://schema.org";
  "@type": "Article";
  headline: string;
  description: string;
  image: string[];
  datePublished: string;
  dateModified: string;
  author: { "@type": "Person"; name: string; url: string };
  publisher: {
    "@type": "Organization";
    name: string;
    logo: { "@type": "ImageObject"; url: string };
  };
}

function getArticleJsonLd(post: BlogPost): ArticleJsonLd {
  return {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: post.title,
    description: post.excerpt,
    image: [post.coverImage],
    datePublished: post.publishedAt,
    dateModified: post.updatedAt || post.publishedAt,
    author: {
      "@type": "Person",
      name: "Ray Martin",
      url: "https://raymartin.es",
    },
    publisher: {
      "@type": "Organization",
      name: "Ray Martin Dev",
      logo: {
        "@type": "ImageObject",
        url: "https://raymartin.es/logo.png",
      },
    },
  };
}

export default async function BlogPostPage({ params }: PageProps) {
  const post = await getPostBySlug(params.slug);

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(getArticleJsonLd(post)),
        }}
      />
      <article>...</article>
    </>
  );
}
typescript
// components/common/Breadcrumbs.tsx
interface BreadcrumbItem {
  name: string;
  url: string;
}

function getBreadcrumbJsonLd(items: BreadcrumbItem[]) {
  return {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    itemListElement: items.map((item, index) => ({
      "@type": "ListItem",
      position: index + 1,
      name: item.name,
      item: item.url,
    })),
  };
}

export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(getBreadcrumbJsonLd(items)),
        }}
      />
      <nav aria-label="Migas de pan">
        <ol className="flex items-center gap-2 text-sm">
          {items.map((item, index) => (
            <li key={item.url}>
              {index < items.length - 1 ? (
                <a href={item.url}>{item.name}</a>
              ) : (
                <span aria-current="page">{item.name}</span>
              )}
            </li>
          ))}
        </ol>
      </nav>
    </>
  );
}

Schema de Organization

typescript
// app/[locale]/layout.tsx
const organizationJsonLd = {
  "@context": "https://schema.org",
  "@type": "Organization",
  name: "Ray Martin Dev",
  url: "https://raymartin.es",
  logo: "https://raymartin.es/logo.png",
  sameAs: [
    "https://github.com/raymartin",
    "https://linkedin.com/in/raymartin",
    "https://twitter.com/raymartin_dev",
  ],
  contactPoint: {
    "@type": "ContactPoint",
    contactType: "servicio al cliente",
    availableLanguage: ["Spanish", "English"],
  },
};

Generacion del sitemap con sitemap.ts

Un sitemap indica a los motores de busqueda que paginas existen en tu sitio y con que frecuencia cambian. El App Router de Next.js soporta la generacion de sitemaps a traves de un archivo sitemap.ts en el directorio app/.

typescript
// app/sitemap.ts
import type { MetadataRoute } from "next";

const baseUrl = "https://raymartin.es";
const locales = ["es", "en"];

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const staticRoutes = [
    "",
    "/services",
    "/experience",
    "/skills",
    "/about",
    "/projects",
  ];

  const staticEntries = locales.flatMap((locale) =>
    staticRoutes.map((route) => ({
      url: `${baseUrl}/${locale}${route}`,
      lastModified: new Date(),
      changeFrequency: "monthly" as const,
      priority: route === "" ? 1.0 : 0.8,
      alternates: {
        languages: Object.fromEntries(
          locales.map((l) => [l, `${baseUrl}/${l}${route}`])
        ),
      },
    }))
  );

  // Entradas dinamicas del blog
  const posts = await getAllPosts();
  const blogEntries = locales.flatMap((locale) =>
    posts.map((post) => ({
      url: `${baseUrl}/${locale}/blog/${post.slug}`,
      lastModified: new Date(post.updatedAt || post.publishedAt),
      changeFrequency: "weekly" as const,
      priority: 0.6,
      alternates: {
        languages: Object.fromEntries(
          locales.map((l) => [l, `${baseUrl}/${l}/blog/${post.slug}`])
        ),
      },
    }))
  );

  return [...staticEntries, ...blogEntries];
}

El sitemap se sirve automaticamente en /sitemap.xml. Para sitios grandes con miles de paginas, considera usar la funcion generateSitemaps para crear multiples archivos de sitemap con un indice.

Configuracion de robots.txt con robots.ts

El archivo robots.ts controla que paginas pueden rastrear los motores de busqueda. Colocalo en el directorio app/ junto a tu sitemap.

typescript
// app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: "*",
        allow: "/",
        disallow: ["/api/", "/admin/", "/_next/"],
      },
      {
        userAgent: "Googlebot",
        allow: "/",
        disallow: "/api/",
      },
    ],
    sitemap: "https://raymartin.es/sitemap.xml",
  };
}

Importante: Incluye siempre la URL de tu sitemap en la salida de robots.txt. Esto asegura que los motores de busqueda puedan descubrir tu sitemap automaticamente sin necesidad de enviarlo manualmente en Google Search Console.

URLs canonicas y etiquetas Hreflang

Las URLs canonicas previenen problemas de contenido duplicado al indicar a los motores de busqueda cual version de una pagina es la "original". Para sitios multilingues, las etiquetas hreflang informan a los buscadores sobre la relacion entre paginas en diferentes idiomas.

typescript
// app/[locale]/layout.tsx
import type { Metadata } from "next";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ locale: string }>;
}): Promise<Metadata> {
  const { locale } = await params;

  return {
    metadataBase: new URL("https://raymartin.es"),
    alternates: {
      canonical: `/${locale}`,
      languages: {
        "es": "/es",
        "en": "/en",
        "x-default": "/es",
      },
    },
  };
}

Esto genera el siguiente HTML en el <head>:

html
<link rel="canonical" href="https://raymartin.es/es" />
<link rel="alternate" hreflang="es" href="https://raymartin.es/es" />
<link rel="alternate" hreflang="en" href="https://raymartin.es/en" />
<link rel="alternate" hreflang="x-default" href="https://raymartin.es/es" />
  • canonical: Apunta a la URL de la pagina actual — evita que los buscadores traten las variantes de idioma como duplicados
  • hreflang: Mapea cada idioma a su URL correspondiente para que Google sirva la version correcta a cada usuario
  • x-default: Especifica la pagina de respaldo para usuarios cuyo idioma no esta explicitamente soportado

Optimizacion de Core Web Vitals

Los Core Web Vitals son un conjunto de metricas que Google utiliza como senales de posicionamiento. Optimizar estas metricas impacta directamente en tu posicionamiento en buscadores y en la experiencia de usuario.

LCP — Largest Contentful Paint

LCP mide la rapidez con la que carga el elemento visible mas grande. El objetivo es menos de 2.5 segundos.

typescript
// Optimizar LCP con imagenes prioritarias y precarga de fuentes
import Image from "next/image";
import { Inter } from "next/font/google";

const inter = Inter({
  subsets: ["latin"],
  display: "swap", // Previene cambios de diseno relacionados con fuentes
});

export function HeroBanner() {
  return (
    <section className={inter.className}>
      <Image
        src="/images/hero.webp"
        alt="Banner principal mostrando un espacio de trabajo de desarrollo web moderno"
        width={1200}
        height={600}
        priority
        fetchPriority="high"
        sizes="100vw"
        quality={85}
      />
      <h1>Desarrollador Fullstack de Producto</h1>
    </section>
  );
}
  • priority: Precarga la imagen y desactiva la carga diferida
  • fetchPriority="high": Indica al navegador que priorice este recurso
  • next/font: Aloja las fuentes localmente y elimina peticiones de red externas
  • display: "swap": Muestra texto de respaldo mientras la fuente carga

CLS — Cumulative Layout Shift

CLS mide el movimiento visual inesperado. El objetivo es menos de 0.1.

typescript
// Prevenir CLS reservando espacio para contenido dinamico
export function VideoEmbed({ videoId }: { videoId: string }) {
  return (
    <div className="relative w-full" style={{ aspectRatio: "16/9" }}>
      <iframe
        src={`https://www.youtube.com/embed/${videoId}`}
        title="Video incrustado"
        className="absolute inset-0 w-full h-full"
        loading="lazy"
        allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope"
        allowFullScreen
      />
    </div>
  );
}

// Siempre especifica ancho y alto para imagenes
<Image
  src="/images/photo.jpg"
  alt="Descripcion de la imagen"
  width={800}
  height={450}
/>

INP — Interaction to Next Paint

INP mide la capacidad de respuesta a las interacciones del usuario. El objetivo es menos de 200ms.

typescript
"use client";

import { useTransition } from "react";

export function SearchFilter({ onFilter }: { onFilter: (q: string) => void }) {
  const [isPending, startTransition] = useTransition();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // Envuelve actualizaciones de estado pesadas en startTransition para mantener la UI responsive
    startTransition(() => {
      onFilter(e.target.value);
    });
  };

  return (
    <div>
      <input
        type="search"
        onChange={handleChange}
        placeholder="Buscar..."
        aria-label="Buscar"
      />
      {isPending && <span className="text-sm text-gray-500">Filtrando...</span>}
    </div>
  );
}

Optimizacion de imagenes para SEO

Las imagenes juegan un papel critico en el SEO. Los motores de busqueda evaluan el texto alternativo, los nombres de archivo, el rendimiento de carga y el dimensionamiento responsive. Next.js proporciona herramientas integradas para manejar todos estos aspectos.

typescript
// Configuracion completa de SEO para imagenes
import Image from "next/image";

export function ProjectCard({ project }: { project: Project }) {
  return (
    <article>
      <Image
        src={project.image}
        alt={`Captura de pantalla de ${project.title} — ${project.shortDescription}`}
        width={600}
        height={400}
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
        loading="lazy"
        className="rounded-lg"
      />
      <h3>{project.title}</h3>
      <p>{project.description}</p>
    </article>
  );
}
  • Texto alternativo: Escribe texto alt descriptivo y unico que explique el contenido de la imagen — evita el relleno de palabras clave
  • Nombres de archivo: Usa nombres descriptivos en kebab-case como nextjs-dashboard-captura.webp
  • Atributo sizes: Ayuda al navegador a elegir el tamano optimo de imagen para el viewport, reduciendo el ancho de banda
  • loading="lazy": Difiere la carga de imagenes que estan debajo del fold hasta que estan cerca del viewport
  • Formatos WebP/AVIF: Next.js sirve automaticamente formatos modernos cuando el navegador los soporta
  • Imagenes responsive: La propiedad sizes combinada con la optimizacion de imagenes de Next.js sirve imagenes del tamano adecuado

Monitorizacion del SEO con Google Search Console

Google Search Console es esencial para monitorizar el rendimiento de busqueda de tu sitio e identificar problemas de SEO. Despues de desplegar tu aplicacion Next.js, configura Search Console para rastrear la indexacion, el rendimiento y cualquier error de rastreo.

Metodos de verificacion

typescript
// app/[locale]/layout.tsx — Verificacion por etiqueta HTML
import type { Metadata } from "next";

export const metadata: Metadata = {
  verification: {
    google: "tu-codigo-de-verificacion-google",
    yandex: "tu-codigo-de-verificacion-yandex",
    other: {
      "msvalidate.01": "tu-codigo-de-verificacion-bing",
    },
  },
};

Metricas clave a monitorizar

  1. Cobertura del indice: Comprueba que paginas estan indexadas e identifica cualquier error de rastreo o paginas excluidas del indice
  2. Informe de rendimiento: Rastrea impresiones, clics, CTR y posicion media para tus palabras clave objetivo
  3. Informe de Core Web Vitals: Monitoriza datos de usuarios reales para LCP, CLS e INP en movil y escritorio
  4. Usabilidad movil: Asegurate de que todas las paginas pasan las pruebas de compatibilidad movil
  5. Sitemaps: Envia tu sitemap y verifica que todas las paginas estan siendo descubiertas
  6. Resultados enriquecidos: Valida que tus datos estructurados JSON-LD generan resultados enriquecidos correctamente

Consejo profesional: Usa la herramienta de Inspeccion de URL para probar paginas individuales. Te muestra exactamente como Googlebot ve tu pagina, incluyendo el HTML renderizado, los datos estructurados detectados y cualquier problema de indexacion. Para aplicaciones Next.js que usan Server Components, el HTML renderizado que ve Googlebot incluye todo el contenido renderizado en el servidor, lo cual es excelente para el SEO.

Combinar una configuracion de metadata adecuada, datos estructurados, optimizacion de rendimiento y monitorizacion continua crea una base solida de SEO. La API de Metadata de Next.js hace que sea sencillo implementar todas estas mejores practicas dentro de tu arquitectura de componentes, manteniendo tus preocupaciones de SEO cerca del contenido que describen.

Compartir:

Artículos relacionados