Saltar al contenido

Canónicas y sitemaps que no pueden derivar

Tu sitemap lista rutas que tu router ya no sirve, y tus etiquetas canónicas contradicen a ambos. El arreglo es estructural — deriva las tres de una sola fuente.

5 min de lectura

Tres cosas le describen a un rastreador las URL de tu sitio: las rutas que tu aplicación sirve de verdad, la etiqueta canónica de cada página y tu sitemap. En la mayoría de los proyectos se mantienen en tres sitios distintos, por tres personas distintas, en tres momentos distintos.

Derivan. Siempre derivan.

Qué aspecto tiene la deriva

  • El sitemap lista /blog/old-post, que devuelve 404.
  • /pricing/ y /pricing devuelven 200 los dos, sin canónica entre ellos.
  • La canónica de la página alemana apunta a la URL inglesa, porque alguien copió el bloque de metadatos.
  • Una ruta nueva se publicó hace tres meses y no está en ningún sitemap.
  • El sitemap tiene https://www.example.com/... mientras el sitio sirve https://example.com/....

Ninguna de estas cosas es dramática. Juntas significan que el rastreador gasta su presupuesto en URL que no te importan y reparte las señales de posicionamiento entre duplicados.

Una fuente, tres salidas

El arreglo estructural: convierte la URL en un valor calculado, y no escribas nunca una a mano.

// site.config.ts — the only place a URL is constructed
export function absoluteUrl(path = '/'): string {
  return `${siteConfig.url}${path.startsWith('/') ? path : `/${path}`}`;
}
 
/**
 * Locale-aware pathname. `en` is the default locale and is served without a
 * prefix, so its canonical URLs stay at the site root.
 */
export function localizedPath(locale: Locale, path = '/'): string {
  const clean = path === '/' ? '' : path.startsWith('/') ? path : `/${path}`;
  return locale === defaultLocale ? clean || '/' : `/${locale}${clean}`;
}
 
export function localizedUrl(locale: Locale, path = '/'): string {
  return absoluteUrl(localizedPath(locale, path));
}

La canónica de cada página, cada entrada del sitemap y cada alternativa hreflang llaman a localizedUrl. Cambia el dominio en una constante y las tres siguen. No hay ocasión para que aparezca una barra final en una salida y no en otra, porque la cadena se construye en exactamente una función.

Haz que la canónica sea imposible de omitir

Una página no debería poder publicarse sin canónica. La forma de imponerlo es hacer pasar los metadatos por un ayudante cuyos argumentos obligatorios incluyan todo lo que una canónica necesita:

export async function generateMetadata({ params }): Promise<Metadata> {
  const { locale, slug } = await params;
  const doc = getDoc('blog', locale, slug);
  if (!doc) return {};
 
  return buildMetadata({
    locale,
    title: doc.title,
    description: doc.description,
    path: `/blog/${slug}`,          // canonical is derived from this
    availableLocales: doc.availableLocales,
    type: 'article',
  });
}

Quien añade una ruta escribe path porque la función no pasa el typecheck sin él. No puede olvidarse de la canónica ni equivocarse en su forma, porque nunca escribe una URL.

El sitemap lee la misma fuente que las páginas

Next.js genera sitemap.xml a partir de app/sitemap.ts. El error es escribir ahí un array estático.

export default function sitemap(): MetadataRoute.Sitemap {
  const entries: MetadataRoute.Sitemap = [];
 
  for (const collection of ['services', 'blog'] as const) {
    for (const locale of locales) {
      // Same helper the pages render from — the two cannot disagree.
      for (const doc of getDocs(collection, locale)) {
        const path = `/${collection}/${doc.slug}`;
        entries.push({
          url: localizedUrl(locale, path),
          lastModified: doc.updated ? new Date(doc.updated) : new Date(doc.date),
          alternates: alternatesFor(path, availableLocalesFor(collection, doc.slug)),
        });
      }
    }
  }
 
  return entries;
}

Despublica un artículo y sale del sitemap en el siguiente build. Añade una traducción y las alternativas crecen. Ningún paso de mantenimiento, porque el sitemap no es un documento — es una proyección.

lastModified es una afirmación, no un formalismo

Poner lastModified a new Date() en cada entrada le dice al rastreador que tu sitio entero cambió hoy, todos los días. Tras unos ciclos deja de creerte. Usa la fecha de modificación real del documento.

Barra final: elige una y mantenla

Next.js sirve sin barra final por defecto. Eso está bien. Lo que no está bien es servir las dos.

// next.config.ts
const nextConfig: NextConfig = {
  trailingSlash: false,  // the default; state it so nobody 'fixes' it later
};

Después confirma que la otra forma redirige en vez de resolver:

curl -sI https://example.com/pricing/ | head -1
# HTTP/2 308   ← correct
# HTTP/2 200   ← two URLs for one page

La misma comprobación vale para www frente al dominio raíz, y para http frente a https. Cada uno debería ser una sola redirección permanente, no una segunda copia viva.

Robots: no indexes tus previsualizaciones

Cada despliegue de rama es una copia completa de tu sitio en una URL pública. Si es indexable, es un duplicado de tu sitio de producción con otro nombre de host.

export default function robots(): MetadataRoute.Robots {
  // Preview and branch deployments must never be indexed.
  const isProduction =
    process.env.VERCEL_ENV === 'production' ||
    process.env.NEXT_PUBLIC_SITE_URL === siteConfig.url;
 
  if (!isProduction) {
    return { rules: [{ userAgent: '*', disallow: '/' }] };
  }
 
  return {
    rules: [{ userAgent: '*', allow: '/', disallow: ['/api/'] }],
    sitemap: absoluteUrl('/sitemap.xml'),
    host: siteConfig.url,
  };
}

Fíjate en que esto también arregla metadataBase. Un despliegue de previsualización que hereda la URL de producción emitirá canónicas de producción desde un host de staging — lo cual es inofensivo — pero uno que emite su propio nombre de host en las canónicas siendo indexable hace daño activamente.

Una verificación de cinco minutos

# Canonical, on the page the crawler sees — not the inspector's hydrated DOM
curl -s https://example.com/de/blog/some-post | grep -E 'canonical|alternate'
 
# Sitemap parses, and the count matches what you expect
curl -s https://example.com/sitemap.xml | grep -c '<loc>'
 
# Every URL in the sitemap returns 200
curl -s https://example.com/sitemap.xml \
  | grep -oP '(?<=<loc>)[^<]+' \
  | xargs -P8 -I{} sh -c 'printf "%s %s\n" "$(curl -o /dev/null -sw "%{http_code}" "{}")" "{}"' \
  | grep -v '^200'

Ese último comando no debería imprimir nada. Si imprime algo, tu sitemap está enviando rastreadores a páginas que no existen — y seguirá haciéndolo hasta que cambie algo estructural en cómo se produce el sitemap.

El cambio estructural es justamente la cuestión. El SEO correcto no es una lista de etiquetas que recordar. Es una arquitectura en la que la etiqueta equivocada no se puede escribir.

Volver a todos los artículos