* notes / next.js
Next.js App Router: sitemaps y el caso del <Head> que rompía la hidratación
— Generación dinámica de sitemaps con app/sitemap.ts y la migración de next/head a la Metadata API, con un error de hidratación real como guía.
Migré este sitio del Pages Router al App Router y las diferencias se notaron desde el primer día. Esta entrada cuenta dos episodios de esa migración: cómo generé sitemaps dinámicos con app/sitemap.ts, y cómo un script metido en un <Head> viejo me rompió la hidratación.
Contexto: del archivo manual al sitemap declarativo
Antes mantenía un pages/sitemap.xml.tsx con getServerSideProps que armaba el XML a mano. Funcionaba, pero había que conocer la sintaxis del estándar y duplicar lógica para el robots.txt. El App Router trae dos archivos especiales en app/: sitemap.ts y robots.ts, con tipos oficiales de Next.
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { getAllPostSlugs } from "@/lib/posts";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPostSlugs();
const base = "https://akzu.dev";
const staticRoutes: MetadataRoute.Sitemap = [
{ url: `${base}/`, lastModified: new Date(), changeFrequency: "weekly", priority: 1 },
{ url: `${base}/lab`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.7 },
{ url: `${base}/posts`, lastModified: new Date(), changeFrequency: "daily", priority: 0.8 },
];
const postRoutes: MetadataRoute.Sitemap = posts.map((s) => ({
url: `${base}/posts/${s.slug}`,
lastModified: s.updatedAt,
changeFrequency: "monthly",
priority: 0.6,
}));
return [...staticRoutes, ...postRoutes];
}Next se encarga de serializar el XML y de la cabecera Content-Type: application/xml. Yo solo devuelvo un array tipado; el resto es automático.
Sitemaps fragmentados con generateSitemaps
Cuando un sitio supera las 50.000 URL (o simplemente quieres un sitemap por categoría), generateSitemaps permite generar varios archivos indexados.
// app/posts/sitemap.ts
import type { MetadataRoute } from "next";
import { getPostsPage, getPagesCount } from "@/lib/posts";
export async function generateSitemaps() {
const total = await getPagesCount(1000);
return Array.from({ length: total }, (_, id) => ({ id }));
}
export default async function sitemap({
id,
}: { id: number }): Promise<MetadataRoute.Sitemap> {
const posts = await getPostsPage(id, 1000);
return posts.map((p) => ({
url: `https://akzu.dev/posts/${p.slug}`,
lastModified: p.updatedAt,
}));
}Next genera automáticamente sitemap/0.xml, sitemap/1.xml, … y un índice. La paginación va implícita en el id. Eso sí: conviene cachear los resultados (con unstable_cache o tu propia capa), porque cada build o revalidate dispara una consulta por sitemap.
robots.ts, su compañero natural
// app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: "*", allow: "/", disallow: ["/api/", "/admin/"] },
{ userAgent: "GPTBot", disallow: "/" },
],
sitemap: "https://akzu.dev/sitemap.xml",
host: "https://akzu.dev",
};
}En robots.ts decido qué crawlers acepto; el sitemap solo lleva el inventario de URLs.
El cambio de paradigma del <Head>
En Pages Router se inyectaban etiquetas con next/head:
// pages/posts/[slug].tsx (legado)
import Head from "next/head";
export default function Post({ post }) {
return (
<>
<Head>
<title>{post.title} · Akzu</title>
<meta name="description" content={post.summary} />
<script src="https://plausible.io/js/plausible.js" defer />
</Head>
<article>{post.body}</article>
</>
);
}En el App Router ese patrón se reemplaza por exports declarativos (metadata o generateMetadata) y, para scripts, por el componente next/script. Si sigues metiendo etiquetas a mano en el <head> desde un componente cliente, te expones a que el HTML del servidor y el árbol de React no coincidan.
El error real que me bloqueó tres horas
Migré las páginas al App Router, dejé las metas como estaban (dentro de un <Head /> que importé sin pensarlo) y el navegador empezó a quejarse:
Hydration failed because the initial UI does not match what was rendered on the server.
Warning: Expected server HTML to contain a matching <script> in <head>.El culpable era un <script src="https://plausible.io/js/plausible.js" defer /> metido en un helper que renderizaba un <Head> en el cliente. El servidor entregaba el <head> sin esa etiqueta; el cliente encontraba un árbol distinto durante la hidratación y abortaba.
La solución fue eliminar ese <Head> improvisado y pasar a tres mecanismos del App Router:
- Metadatos vía
metadataogenerateMetadata. - Scripts vía
next/scriptcon su estrategia. - Bloques personalizados de
<head>vía un layout específico, no por componentes huérfanos.
// app/posts/[slug]/page.tsx
import type { Metadata } from "next";
import Script from "next/script";
import { getPost } from "@/lib/posts";
import { MDXRemote } from "next-mdx-remote/rsc";
export async function generateMetadata(
{ params }: { params: { slug: string } },
): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: `${post.title} · Akzu`,
description: post.summary,
openGraph: {
title: post.title,
description: post.summary,
type: "article",
publishedTime: post.publishedAt,
},
alternates: { canonical: `/posts/${post.slug}` },
};
}
export default async function Page({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return (
<>
<Script
src="https://plausible.io/js/plausible.js"
strategy="afterInteractive"
data-domain="akzu.dev"
/>
<article className="prose">
<MDXRemote source={post.source} />
</article>
</>
);
}Con eso, el HTML del servidor y el árbol del cliente vuelven a coincidir y el warning desaparece. Aquí prefiero MDXRemote (renderiza desde el árbol MDX) en lugar de inyectar HTML crudo; así tampoco tengo que preocuparme por sanearlo.
Render: Pages vs. App Router
Hallazgos durante la migración
- Las metas son cosa del servidor. Si declaro
metadataen un archivo con"use client", Next lo ignora sin avisar. La página o el layout tienen que quedarse en el servidor y delegar la interactividad a hijos cliente. generateMetadatase cachea por petición dentro del mismo render tree, así que si dentro consulto el mismo dato que la página no me preocupa duplicar la lectura: Next deduplica concache()yfetch()automáticamente.viewportythemeColormigraron a su propio exportviewport. Mantenerlos dentro demetadataproduce un warning y los descarta.sitemap.tses estático por defecto; para regenerarlo con frecuencia convieneexport const revalidate = 3600o llamarlo desderevalidatePath('/sitemap.xml')cuando publico contenido.- El
Sitemapadmiteimagesyvideosen cada entrada (subobjetos con sus propios metadatos). Útil para sitios con galerías.