Por qué Next.js App Router cambia el SEO técnico
Hasta la llegada del App Router en Next.js 13, gestionar el SEO técnico requería dependencias externas como next-seo o manipular el _document.tsx de formas poco elegantes. Con el App Router, Next.js incorporó un sistema de Metadata API que resuelve de forma nativa prácticamente todo lo que necesitas para un SEO técnico sólido.
Esta guía asume que estás usando Next.js 14 o superior con el directorio app/. No es aplicable al Pages Router (pages/). Todos los ejemplos de código son copiables y funcionales.
1. Metadata estática con el objeto metadata
Para páginas cuyo título y descripción no cambian (como la página de inicio o la página de contacto), exporta un objeto metadata directamente desde el archivo de página:
// app/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Desarrollo web Next.js en Madrid | Javier Lozano",
description:
"Desarrollo de webs y aplicaciones web con Next.js, Tailwind CSS y automatizaciones con n8n para pymes en Madrid y toda España.",
keywords: ["desarrollo web madrid", "nextjs freelance españa", "automatización n8n"],
authors: [{ name: "Javier Lozano", url: "https://javierlozano.dev" }],
creator: "Javier Lozano",
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
openGraph: {
type: "website",
locale: "es_ES",
url: "https://javierlozano.dev",
siteName: "Javier Lozano — Desarrollo Web",
title: "Desarrollo web Next.js en Madrid | Javier Lozano",
description:
"Webs rápidas, seguras y optimizadas para SEO con Next.js para pymes en España.",
images: [
{
url: "https://javierlozano.dev/og-home.jpg",
width: 1200,
height: 630,
alt: "Javier Lozano — Desarrollo web en Madrid",
},
],
},
twitter: {
card: "summary_large_image",
title: "Desarrollo web Next.js en Madrid | Javier Lozano",
description: "Webs rápidas y optimizadas para pymes en España.",
images: ["https://javierlozano.dev/og-home.jpg"],
creator: "@javier.codes",
},
alternates: {
canonical: "https://javierlozano.dev",
},
};
2. Metadata dinámica con generateMetadata
Para páginas cuya metadata cambia según el contenido (artículos de blog, páginas de producto, perfiles de usuario), usa la función generateMetadata:
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPostBySlug } from "@/lib/blog";
import { notFound } from "next/navigation";
type Props = {
params: { slug: string };
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPostBySlug(params.slug);
if (!post) {
return {
title: "Artículo no encontrado",
};
}
return {
title: post.metaTitle, // max 60 caracteres
description: post.metaDescription, // max 155 caracteres
keywords: post.keywords,
openGraph: {
type: "article",
locale: "es_ES",
url: `https://javierlozano.dev/blog/${post.slug}`,
title: post.metaTitle,
description: post.metaDescription,
publishedTime: post.date,
authors: ["https://javierlozano.dev/sobre-mi"],
images: [
{
url: `https://javierlozano.dev/blog/${post.slug}/og.jpg`,
width: 1200,
height: 630,
alt: post.title,
},
],
},
alternates: {
canonical: `https://javierlozano.dev/blog/${post.slug}`,
},
};
}
export default async function BlogPostPage({ params }: Props) {
const post = await getPostBySlug(params.slug);
if (!post) notFound();
// ...
}
3. Título dinámico con template en el layout raíz
Para que todas las páginas compartan un sufijo de marca en el título sin repetirlo en cada archivo, usa el campo template en el layout raíz:
// app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: {
default: "Javier Lozano — Desarrollo Web Next.js Madrid",
template: "%s | Javier Lozano",
// Resulta en: "SEO técnico con Next.js | Javier Lozano"
},
description: "Descripción por defecto si la página no define la suya",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es">
<body>{children}</body>
</html>
);
}
4. Sitemap dinámico
El archivo app/sitemap.ts genera automáticamente un sitemap XML que Next.js sirve en /sitemap.xml. Si tienes contenido dinámico (blog, productos), puedes incluirlo consultando tu fuente de datos:
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { getAllPosts } from "@/lib/blog";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
const baseUrl = "https://javierlozano.dev";
const staticPages: MetadataRoute.Sitemap = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 1.0,
},
{
url: `${baseUrl}/sobre-mi`,
lastModified: new Date(),
changeFrequency: "yearly",
priority: 0.8,
},
{
url: `${baseUrl}/servicios`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.9,
},
{
url: `${baseUrl}/blog`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.8,
},
{
url: `${baseUrl}/contacto`,
lastModified: new Date(),
changeFrequency: "yearly",
priority: 0.7,
},
];
const blogPages: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.date),
changeFrequency: "monthly" as const,
priority: 0.7,
}));
return [...staticPages, ...blogPages];
}
5. robots.txt con la API nativa
Similar al sitemap, Next.js genera el robots.txt a partir de un archivo en app/robots.ts:
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/api/", "/admin/", "/_next/"],
},
],
sitemap: "https://javierlozano.dev/sitemap.xml",
host: "https://javierlozano.dev",
};
}
6. JSON-LD con Schema.org en el layout
El marcado estructurado ayuda a Google a entender mejor quién eres y qué ofreces. Para una web personal/profesional, el schema más relevante es Person o LocalBusiness. Colócalo en el layout raíz para que aparezca en todas las páginas:
// app/layout.tsx (añadir dentro del <body>)
const personSchema = {
"@context": "https://schema.org",
"@type": "Person",
name: "Javier Lozano",
url: "https://javierlozano.dev",
jobTitle: "Desarrollador Web y Especialista en IA",
worksFor: {
"@type": "Organization",
name: "Hexagon Developers",
url: "https://javierlozano.dev",
},
address: {
"@type": "PostalAddress",
addressLocality: "Getafe",
addressRegion: "Madrid",
addressCountry: "ES",
},
sameAs: [
"https://twitter.com/javier.codes",
"https://github.com/javierlzsn",
"https://linkedin.com/in/javier-lozanos",
],
knowsAbout: ["Next.js", "React", "Tailwind CSS", "n8n", "SEO técnico"],
};
// En el JSX del layout:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(personSchema) }}
/>
Para artículos de blog, añade schema Article en la página de cada post:
// app/blog/[slug]/page.tsx
const articleSchema = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.metaDescription,
author: {
"@type": "Person",
name: "Javier Lozano",
url: "https://javierlozano.dev/sobre-mi",
},
publisher: {
"@type": "Organization",
name: "Javier Lozano",
logo: {
"@type": "ImageObject",
url: "https://javierlozano.dev/logo.png",
},
},
datePublished: post.date,
dateModified: post.updatedAt ?? post.date,
mainEntityOfPage: {
"@type": "WebPage",
"@id": `https://javierlozano.dev/blog/${post.slug}`,
},
};
7. URL canónica: evitar contenido duplicado
Las URL canónicas son especialmente importantes cuando tienes paginación, filtros de URL o cuando el mismo contenido es accesible desde varias rutas. En Next.js se configuran en el campo alternates.canonical de la metadata:
export const metadata: Metadata = {
alternates: {
canonical: "https://javierlozano.dev/blog/mi-articulo",
// Para versiones en otros idiomas:
languages: {
"es-ES": "https://javierlozano.dev/blog/mi-articulo",
"en-US": "https://javierlozano.dev/en/blog/my-article",
},
},
};
8. Hreflang para España y audiencias hispanohablantes
Si tu web tiene versiones en varios idiomas o si quieres segmentar entre España y Latinoamérica, el atributo hreflang indica a Google qué versión mostrar a cada audiencia. En Next.js App Router puedes implementarlo en el layout a través de alternates.languages:
// Para una web solo en español dirigida a España:
export const metadata: Metadata = {
alternates: {
canonical: "https://javierlozano.dev",
languages: {
"es-ES": "https://javierlozano.dev",
"x-default": "https://javierlozano.dev",
},
},
};
// Para una web multi-idioma (español + inglés):
export const metadata: Metadata = {
alternates: {
canonical: "https://javierlozano.dev/es",
languages: {
"es-ES": "https://javierlozano.dev/es",
"en-US": "https://javierlozano.dev/en",
"x-default": "https://javierlozano.dev/es",
},
},
};
9. Open Graph Image dinámica con next/og
Next.js incluye la librería @vercel/og integrada para generar imágenes de Open Graph dinámicas a partir de JSX. Esto permite crear imágenes de preview personalizadas para cada artículo sin herramientas externas:
// app/blog/[slug]/og/route.tsx
import { ImageResponse } from "next/og";
export const runtime = "edge";
export async function GET(
request: Request,
{ params }: { params: { slug: string } }
) {
const post = await getPostBySlug(params.slug);
return new ImageResponse(
(
<div
style={{
display: "flex",
flexDirection: "column",
background: "#0f172a",
width: "100%",
height: "100%",
padding: "60px",
justifyContent: "space-between",
}}
>
<span style={{ color: "#38bdf8", fontSize: 24 }}>javzcode.com</span>
<h1 style={{ color: "white", fontSize: 56, lineHeight: 1.2 }}>
{post.title}
</h1>
<span style={{ color: "#94a3b8", fontSize: 20 }}>{post.category}</span>
</div>
),
{ width: 1200, height: 630 }
);
}
10. Checklist de SEO técnico para antes de lanzar
Antes de publicar cualquier web Next.js, verifica estos puntos:
- Metadata: todas las páginas tienen
titleúnico ydescriptionentre 120-155 caracteres - Canonical: todas las páginas definen su URL canónica
- Sitemap:
/sitemap.xmles accesible y válido (valídalo en Google Search Console) - robots.txt:
/robots.txtapunta al sitemap y no bloquea páginas indexables - Open Graph: todas las páginas tienen imagen OG de 1200×630 px
- JSON-LD: schema
PersonoOrganizationen el layout raíz - Core Web Vitals: LCP < 2,5 s, INP < 200 ms, CLS < 0,1 (verifica con PageSpeed Insights)
- HTTPS: el sitio solo es accesible por HTTPS, HTTP redirige con 301
- lang="es": el atributo
langen la etiqueta<html>está definido - Google Search Console: el sitio está verificado y el sitemap enviado
¿Necesitas que implemente todo esto en tu web?
Configurar correctamente el SEO técnico de un proyecto Next.js lleva tiempo y requiere conocer bien el App Router. Si tienes una web existente o estás a punto de lanzar una nueva y quieres asegurarte de que está optimizada desde el primer día, escríbeme y hablamos. Hago auditorías de SEO técnico e implementación de mejoras con entregables concretos y medibles.