Escritos

SEO en Next.js: metadata, sitemap, robots y datos estructurados

La mecánica del SEO en el App Router: generateMetadata, etiquetas canonical, cómo configurar bien lastModified en tu sitemap, estructurar JSON-LD con @graph y qué rich results siguen realmente vivos.

Furkan Özay2 de septiembre de 2026 · 7 min de lectura
czCZAY.DEV // EDITORIALES
MODERN WEB & REACT

#Web Temelleri · 7 MIN DE LECTURA

FURKAN ÖZAYVOL. 2026 // #2A05

El SEO en Next.js no consiste en instalar plugins: son solo unos cuantos archivos y unas cuantas funciones. Pero puede resultar confuso saber qué hace cada cosa, y la mayoría de las guías que circulan tienen información desactualizada.

Este artículo desglosa una por una las superficies de SEO en el App Router. Todos los ejemplos proceden del código real que se ejecuta en este mismo sitio.

1. Metadata: estática y generada

Hay dos formatos. Para las páginas estáticas, el objeto metadata:

TypeScript
// app/studio/page.tsx
export const metadata: Metadata = {
	title: "Czay Studio",
	description: "Work I've delivered for companies and brands…",
	alternates: { canonical: "https://czay.dev/studio" },
	openGraph: {
		title: "Czay Studio | Furkan Özay",
		description: "…",
		url: "https://czay.dev/studio",
	},
};

Para las páginas dinámicas, generateMetadata:

TypeScript
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
	const { slug } = await params;
	const post = getPost(slug);
	if (!post) return {};
 
	return {
		title: post.title,
		description: post.description,
		alternates: { canonical: `https://czay.dev/blog/${slug}` },
		openGraph: {
			title: `${post.title} | Furkan Özay`,
			description: post.description,
			url: `https://czay.dev/blog/${slug}`,
			type: "article",
			publishedTime: post.date,
			modifiedTime: post.updated ?? post.date,
			authors: ["Furkan Özay"],
			tags: post.tags,
		},
	};
}

Advertencia: No olvides modifiedTime

Añadí esta línea mientras escribía este artículo. El sitio tenía publishedTime pero le faltaba modifiedTime: los artículos que había actualizado seguían pareciendo sus versiones iniciales al compartirlos y en los navegadores. Si tienes por costumbre actualizar tus artículos, este campo es la señal más barata para comunicar que el contenido está al día.

Plantilla raíz

Si defines una plantilla de título en layout.tsx, no tienes que reescribir el nombre de tu marca en todas las páginas:

TypeScript
export const metadata: Metadata = {
	metadataBase: new URL("https://czay.dev"),
	title: {
		default: "Furkan Özay — Fullstack Developer & Teacher",
		template: "%s | Furkan Özay",
	},
};

metadataBase es especialmente importante: sin él, las URL relativas de las imágenes de Open Graph no se resuelven a URL absolutas y tus tarjetas al compartir en redes sociales aparecerán en blanco.

2. Canonical: el campo más pasado por alto

Cada página debería declarar su propia URL canonical. Si no la defines, las distintas rutas que llevan al mismo contenido (parámetros de seguimiento, barras finales, la variante con www) se tratan como páginas independientes y se dividen tus señales de SEO.

TypeScript
alternates: { canonical: "https://czay.dev/studio/mobile" }

Esto es aún más crítico si guardas el estado de los filtros de tu blog en la barra de direcciones: una lista cargada con ?topic=nextjs no es una página distinta, es solo una vista de la misma página.

3. sitemap.ts y robots.ts

Ambos son simplemente archivos; ya no escribes XML a mano.

TypeScript
// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
	const posts = getAllPosts().map((post) => ({
		url: `https://czay.dev/blog/${post.slug}`,
		lastModified: new Date(post.updated ?? post.date),
		changeFrequency: "monthly" as const,
		priority: 0.7,
	}));
 
	return [...staticPages, ...posts];
}
TypeScript
// app/robots.ts
export default function robots(): MetadataRoute.Robots {
	return {
		rules: { userAgent: "*", allow: "/" },
		sitemap: "https://czay.dev/sitemap.xml",
	};
}

Precaución: No rellenes lastModified con la fecha de publicación

Este fue otro error que detecté y corregí mientras escribía este artículo: el sitemap usaba new Date(post.date), lo que ignoraba por completo el campo updated. Había actualizado seis artículos y el sitemap seguía sirviéndolos con sus fechas antiguas, desperdiciando la señal de rastreo.

La forma correcta: new Date(post.updated ?? post.date)

Presta atención también a qué páginas entran realmente en tu sitemap. En este sitio, las páginas de detalle de los servicios existen para encontrarse de forma individual; dejarlas fuera del sitemap anularía ese propósito:

TypeScript
const servicePages = studioServices
	.filter((service) => service.detail)
	.map((service) => ({
		url: `https://czay.dev/studio/${service.id}`,
		changeFrequency: "monthly" as const,
		priority: 0.8,
	}));

4. Datos estructurados (JSON-LD)

Next.js no tiene una API integrada para esto; tú mismo renderizas la etiqueta script.

TSX
const jsonLd = {
	"@context": "https://schema.org",
	"@type": "BlogPosting",
	headline: post.title,
	datePublished: post.date,
	dateModified: post.updated ?? post.date,
	author: { "@type": "Person", name: "Furkan Özay" },
};
 
export default function Page() {
	return (
		<>
			<script
				type="application/ld+json"
				dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
			/>
			{/* … */}
		</>
	);
}

Usar JSON.stringify es crucial: si intentas montar el texto a mano, las comillas y los caracteres < de tu contenido romperán el schema.

Configura varios schemas con @graph

Si necesitas definir varias entidades en una misma página, en lugar de usar etiquetas script separadas, utiliza un único @graph: así las entidades pueden referenciarse entre sí mediante @id:

TypeScript
{
	"@context": "https://schema.org",
	"@graph": [
		{ "@type": "Service", "@id": `${url}#service`, name: "Mobile app", /* … */ },
		{ "@type": "BreadcrumbList", "@id": `${url}#breadcrumb`, /* … */ },
		{ "@type": "FAQPage", "@id": `${url}#faq`, /* … */ },
	],
}

Importante: ¿Qué rich results siguen realmente vivos?

Circulan muchos consejos desactualizados sobre esto. Este es el panorama actual:

  • BreadcrumbList: sigue funcionando; muestra la jerarquía «sitio › sección › página» en los resultados de búsqueda. Implementación sencilla, beneficio claro.
  • Article / BlogPosting: no genera un cuadro de rich snippet, pero expone las fechas, el autor y la información de modificación al rastreador.
  • FAQPage: Google restringió los rich results de FAQ a sitios de autoridad en agosto de 2023 y los retiró por completo el 7 de mayo de 2026. El marcado sigue siendo válido y lo leen consumidores que no son Google, pero esperar un cuadro de snippet en Google es ya un error.

Así que el consejo de «añadamos un schema de FAQ para conseguir rich results» está obsoleto. Usa los datos estructurados para describir tu página, no para cazar snippets.

No pongas en el schema contenido que no se ve en pantalla

Los datos estructurados describen el contenido real de la página. Añadir a tu schema una FAQ que no existe en la página renderizada infringe las políticas de los buscadores y puede acarrearte una penalización.

5. Generación estática y generateStaticParams

La mitad invisible del SEO es la velocidad. Si no escribes esto para las rutas dinámicas, cada página se vuelve a construir en cada solicitud:

TypeScript
export async function generateStaticParams() {
	return getAllPosts().map((post) => ({ slug: post.slug }));
}

Lo mismo se aplica a las páginas de detalle de los servicios: si tienes una lista finita y conocida, todas deberían pre-renderizarse en tiempo de build.

6. Imágenes de Open Graph

Next.js puede generar imágenes dinámicamente con código; no necesitas diseñar a mano una imagen para cada artículo:

plaintext
app/blog/[slug]/opengraph-image.tsx

Este archivo devuelve un componente de React y Next.js lo renderiza como una imagen de 1200×630. Puedes inyectarle directamente el título, las etiquetas y el tiempo de lectura.

7. El RSS sigue siendo valioso

Generar un feed con app/rss.xml/route.ts requiere solo unas pocas líneas de código. Aunque el número de lectores parezca bajo, el RSS es la forma más fácil de llevar tu contenido a agregadores y newsletters, y esos enlaces entrantes son reales.

Checklist

Resumen: Antes de salir a producción

  • ¿Hay un metadataBase y una plantilla de título en el layout.tsx raíz?
  • ¿Está rellenado alternates.canonical en todas las páginas?
  • ¿generateMetadata proporciona publishedTime y modifiedTime en las páginas dinámicas?
  • ¿El lastModified de sitemap.ts es realmente la fecha de la última actualización?
  • ¿Están incluidas en el sitemap las páginas que quieres que aparezcan por separado en los resultados de búsqueda?
  • ¿Escribiste generateStaticParams para las rutas dinámicas?
  • ¿El JSON-LD se renderiza con JSON.stringify?
  • ¿Todo lo que se declara en el schema existe realmente en la página?
  • ¿robots.ts declara el sitemap?

Corregí dos puntos de esta lista en mi propio sitio mientras escribía este artículo. Lo difícil del SEO no es no saber; es descuidarlo y no volver a revisarlo una vez configurado.

Artículo relacionado: MDX con App Router: la forma correcta de crear un blog

Hoja de ruta y herramienta interactivas

Sigue leyendo