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.

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

La versión multilingüe de esa arquitectura falla a su manera, y hreflang es donde se ve primero. La otra mitad de la misma disciplina es marcado que un rastreador puede leer sin adivinar. Cuando las etiquetas equivocadas ya están en producción y alguien tiene que encontrarlas todas, el SEO técnico es el encargo que las encuentra.

Preguntas relacionadas

¿El sitemap debe listar cada idioma por separado?
Una entrada por URL con las alternativas dentro, no una entrada por idioma. Y anuncie un idioma solo donde la página exista, o estará enviando rastreadores a sus propios 404.
¿Los canonical tienen que ser absolutos?
Sí. Un canonical relativo se resuelve contra la URL actual, con lo que se vuelve autorreferencial por accidente y no sirve como señal.
¿El canonical debe conservar los parámetros?
Solo los que cambian el contenido, como un número de página. Los de seguimiento y los filtros deben canonicalizar a la URL limpia, o multiplicará una misma página por todo el índice.
¿Cómo sabemos que el sitemap es honesto?
Pida cada URL que contiene y compruebe que responde 200. Cualquier otra cosa significa que el sitemap manda rastreadores a páginas que no existen, y seguirá haciéndolo hasta que se derive de la misma fuente que las rutas.

← Volver a todos los artículos

Llamar+1 848 272 7583WhatsApp+90 850 308 5436Correoinfo@codefacture.comPágina de contacto