Casi todos los templates de portafolio y blog en Astro vienen con “SEO”. Abres uno y encuentras un <title>, una meta description y una imagen de Open Graph. Eso es real y está bien — pero también es la parte que te dan gratis. No es una configuración; es un valor por defecto.
La configuración de SEO en Astro que de verdad se acumula mes con mes es la plomería de abajo: datos estructurados para que los buscadores entiendan qué es cada página, canonicals que no apuntan a un 404, hreflang que no se te revierte silenciosamente en un sitio multilingüe, un sitemap que solo anuncia las páginas que de verdad quieres indexadas, y noindex en las páginas delgadas que de otro modo te diluyen. Nada de esto es difícil. Todo se omite. Aquí está completo, con código real de este sitio — y dos errores de un solo carácter que yo mismo cometí y que pueden desindexar un post sin un solo error visible.
1. Datos estructurados: dile a los buscadores qué es la página
Un buscador puede leer tu HTML, pero los datos estructurados (JSON-LD siguiendo schema.org) se lo dicen, sin ambigüedad: esto es una Organization, esto es un blog post, este es el autor, esta es la fecha de publicación. Eso es lo que habilita los rich results y lo que hace que tu sitio se entienda como una entidad y no como una bolsa de texto.
La mayoría de los templates no traen nada de esto. Así es como cada post de este sitio emite un nodo BlogPosting:
---
// src/components/common/StructuredData.astro (BlogPosting branch)
structuredData = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt || '',
image: postImage,
datePublished: post.publishDate.toISOString(),
dateModified: post.updateDate?.toISOString() || post.publishDate.toISOString(),
author: { '@type': 'Person', name: post.author, url: `${siteUrl}/about` },
publisher: {
'@type': 'Organization',
name: siteName,
logo: { '@type': 'ImageObject', url: `${siteUrl}/favicon.ico` },
},
mainEntityOfPage: { '@type': 'WebPage', '@id': postUrl },
url: postUrl,
};
---
{Object.keys(structuredData).length > 0 && (
<script type="application/ld+json" set:html={JSON.stringify(structuredData)} />
)}El home emite un nodo Organization (tu logo, tus perfiles sociales vía sameAs) y un nodo WebSite con un SearchAction — lo que te puede dar un sitelinks search box. Las páginas de post agregan un BreadcrumbList para que el SERP muestre tu jerarquía en vez de una URL cruda.
Dos reglas que evitan que esto se vuelva un problema:
dateModifiedtiene que ser honesto. Volver adatePublishedcuando el post no se ha actualizado (como en el código de arriba) es lo correcto. Fingir undateModifiedfresco en cada deploy para aparentar “actualizado recientemente” es un patrón que Google lleva años aprendiendo a ignorar.- Valídalo una vez. Pega una URL en vivo en el Rich Results Test de Google. Un nodo JSON-LD mal formado es peor que no tener ninguno — puede suprimir el rich result por completo.
2. Canonicals: el typo de un solo carácter que desindexa un post
Una URL canónica le dice a los buscadores “esta es la dirección real de esta página; consolida aquí todas las señales de ranking”. Toda página debería llevar una, y en la mayoría de los casos debería apuntar a sí misma:
---
// src/components/common/Metadata.astro
const {
canonical = String(getCanonical(String(Astro.url.pathname))),
} = Astro.props;
---
<AstroSeo canonical={canonical} ... />Derivar el canonical de la URL real — en vez de dejar que los autores hardcodeen uno por post — es el default seguro, porque el modo de falla de un canonical hardcodeado es brutal. Una vez publiqué un post cuyo canonical en el front-matter tenía un typo de una sola letra: la-mejore-forma en vez de la-mejor-forma. La página estaba viva y funcionaba bien, pero su canonical apuntaba a una URL que no existía. Para Google, ese post estaba declarando “la versión real de mí está allá” — en un 404. Eso es una vía rápida para que te saquen del índice, y nada en el build te avisa.
La regla: deja que el canonical tome por defecto la URL propia de la página, y solo sobrescríbelo deliberadamente — para un cross-post sindicado, apúntalo de vuelta al original. Si vas a hardcodear uno, trátalo como una URL de producción, porque lo es.
3. hreflang: en un sitio multilingüe, la etiqueta equivocada es peor que no tener ninguna
Si sirves más de un idioma, hreflang le dice a Google qué versión mostrarle a qué usuario. Este sitio es principalmente en español con contenido en inglés bajo /en/, así que cada página anuncia sus alternativas más un x-default:
---
// src/components/common/Metadata.astro
const alternateUrls = getAlternateUrls(Astro.url);
---
{alternateUrls.map(({ locale, url }) => (
<link rel="alternate" hreflang={locale === 'es' ? 'es' : 'en'} href={url} />
))}
<link rel="alternate" hreflang="x-default"
href={alternateUrls.find((a) => a.locale === 'es')?.url || canonical} />Aquí está la trampa en la que caí, y es sutil: un post que solo existe en inglés no tiene versión en español. Si su hreflang seguía anunciando una alternativa es, esa alternativa apuntaría a una URL que da 404 — y un cluster de hreflang roto hace que se ignore el conjunto completo. Así que el contenido que existe en un solo idioma tiene que auto-referenciarse únicamente:
// content that exists in exactly one language advertises only itself
if (isEnglishOnly(url.pathname)) {
return [{ locale: 'en', url: new URL(url.pathname, url.origin).toString() }];
}La regla general: hreflang debe ser recíproco y cada URL que contiene debe devolver 200. Una alternativa que apunta a un 404 no solo falla para esa página — puede invalidar las anotaciones de todas las páginas a las que hace referencia. Si no puedes garantizar que la URL del otro idioma existe, no la anuncies.
4. Higiene del sitemap: audita lo que realmente estás anunciando
Generar un sitemap es lo mínimo indispensable — en Astro es una sola integración:
// astro.config.mjs
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://heroweb.dev', // required, or URLs come out relative and useless
integrations: [sitemap()],
});La parte que la gente se salta es leer su propio sitemap. Un sitemap es una lista de páginas que le estás pidiendo activamente a Google que rastree e indexe — así que cualquier cosa ahí adentro que no quieras indexada es un problema de calidad autoinfligido. Al auditar el mío encontré una página legal de relleno — texto boilerplate de “Nombre de la Empresa” con fecha de 2023 — que nunca había enlazado en la navegación pero que se seguía generando, y por lo tanto seguía apareciendo en sitemap-index.xml, invitando a Google a indexar contenido legal falso en mi dominio.
Abre https://tusitio.com/sitemap-index.xml (y los sitemaps hijos a los que apunta) y lee cada URL. Para todo lo que no debería rankear — páginas de relleno, rutas de utilidad delgadas, paginaciones duplicadas — elimina la página o ponle noindex, que nos lleva a la última pieza.
5. Pon noindex a lo delgado, a propósito
Más páginas indexadas no es mejor. Cincuenta páginas de archivo de tags casi vacías, cada una listando un solo post, son cincuenta páginas delgadas compitiendo con tu contenido real por presupuesto de rastreo y señales de calidad. Por eso este sitio indexa los archivos de categoría pero deliberadamente le pone noindex a los archivos de tags:
# src/config.yaml
apps:
blog:
tag:
isEnabled: true # tags still work for navigation
robots:
index: false # but they don't get indexedLos tags siguen siendo útiles para los lectores que navegan el sitio; simplemente no le piden a Google que rankee una docena de listas de un solo elemento. La misma lógica aplica a los resultados de búsqueda interna, a los archivos paginados /page/2 y a cualquier cosa autogenerada y delgada. Indexa lo que te daría orgullo que alguien encontrara en frío. Ponle noindex a lo demás.
6. RSS que apunta a casa
RSS no está muerto — alimenta lectores, newsletters y plataformas de sindicación, y cada una de esas es un lugar por donde viaja tu contenido. Lo único que importa para el SEO: cada ítem enlaza a su URL canónica, para que dondequiera que el feed se republique, la señal de ranking regrese a ti:
// src/pages/rss.xml.ts
const postUrl = getCanonical(addLocaleToPath(getPermalink(post.permalink, 'post'), locale));
return { link: postUrl, title: post.title, description: post.excerpt || '', pubDate: post.publishDate };La lección real: el SEO de un sitio pequeño es plomería, no trucos
Nada de esto es un growth hack. No hay keyword-stuffing, ni esquema de enlaces, ni exploit ingenioso. Son cinco o seis piezas de plomería que cada una toma una tarde en conectarse correctamente — y después todo el juego se vuelve mantenerlas correctas. Los modos de falla no son dramáticos; son un canonical con typo, una página huérfana en el sitemap, un hreflang apuntando a un 404. Silenciosos, individualmente pequeños, y colectivamente la diferencia entre un sitio que se acumula y uno que se filtra en silencio.
Si estás en un theme estilo AstroWind, mucho de esto está a medias y vale la pena auditarlo en vez de asumirlo. Mi análisis a fondo del template AstroWind recorre cómo encajan sus capas de configuración, y si quieres dos historias concretas de un default del theme fallando en silencio, mira la trampa de Partytown que mató mis analytics y los breaking changes que me mordieron en la migración de Astro 5 a 6. Mismo theme, misma lección siempre: los defaults son un punto de partida, no una línea de meta.
Ve a leer tu propio sitemap. Toma cinco minutos, y podrías encontrar una página de términos de relleno invitando a Google a indexar texto que ni siquiera escribiste.