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.
// 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.
// 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.
// 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.
// 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.
// 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
// 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>
</>
);
}Schema de BreadcrumbList
// 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
// 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/.
// 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.
// 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.
// 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>:
<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.
// 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.
// 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.
"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.
// 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
sizescombinada 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
// 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
- Cobertura del indice: Comprueba que paginas estan indexadas e identifica cualquier error de rastreo o paginas excluidas del indice
- Informe de rendimiento: Rastrea impresiones, clics, CTR y posicion media para tus palabras clave objetivo
- Informe de Core Web Vitals: Monitoriza datos de usuarios reales para LCP, CLS e INP en movil y escritorio
- Usabilidad movil: Asegurate de que todas las paginas pasan las pruebas de compatibilidad movil
- Sitemaps: Envia tu sitemap y verifica que todas las paginas estan siendo descubiertas
- 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.