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:
// 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:
// 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:
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.
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.
// 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];
}// 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:
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.
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:
{
"@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:
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:
app/blog/[slug]/opengraph-image.tsxEste 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
metadataBasey una plantilla de título en ellayout.tsxraíz? - ¿Está rellenado
alternates.canonicalen todas las páginas? - ¿
generateMetadataproporcionapublishedTimeymodifiedTimeen las páginas dinámicas? - ¿El
lastModifieddesitemap.tses 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
generateStaticParamspara 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.tsdeclara 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
Desarrollador Backend Junior 2026
Lo que realmente necesitas saber para conseguir un puesto backend junior en 2026 y lo que puede esperar, filtrando las trampas de palabras de moda de las ofertas reales.
¿SQL o NoSQL?
Responde seis preguntas y te diré qué tipo de base de datos encaja con tu proyecto, y por qué.