* 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.

November 25, 20246 min read#next.js#app-router#seo#hidratación

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:

  1. Metadatos vía metadata o generateMetadata.
  2. Scripts vía next/script con su estrategia.
  3. 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

Pages Router (legado)getServerSidePropsnext/headhidratación clientemismatch posibleApp Routerserver componentgenerateMetadataScript (next/script)head injectionorquestada porNext, no por elcomponente

Hallazgos durante la migración

  1. Las metas son cosa del servidor. Si declaro metadata en 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.
  2. generateMetadata se 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 con cache() y fetch() automáticamente.
  3. viewport y themeColor migraron a su propio export viewport. Mantenerlos dentro de metadata produce un warning y los descarta.
  4. sitemap.ts es estático por defecto; para regenerarlo con frecuencia conviene export const revalidate = 3600 o llamarlo desde revalidatePath('/sitemap.xml') cuando publico contenido.
  5. El Sitemap admite images y videos en cada entrada (subobjetos con sus propios metadatos). Útil para sitios con galerías.