SEO en 2026, cuando la mitad de tu tráfico llega por ChatGPT
Una pasada de fin de semana sobre las superficies públicas de un SaaS
chico, reconstruido para los canales de descubrimiento que de verdad
existen en 2026. Empecé con una línea base sólida pero con forma de
2018 (PageMeta + sitemap.xml + robots.txt) y terminé con JSON-LD en
cada superficie pública, un mapa de sitio dinámico, un llms.txt
apuntado a los rastreadores de IA, avisos de IndexNow al publicar,
telemetría de Web Vitals de usuarios reales, y capturas de HTML por
ruta para que los bots de IA sin motor de JavaScript de verdad vean el
meta específico de cada ruta. Sin Puppeteer en la compilación.
TL;DR
| Capa | Antes | Después |
|---|---|---|
| Meta por página (title/desc/canonical/OG/Twitter) | Envoltorio PageMeta en 52/139 páginas |
sin cambios; el patrón ya estaba correcto |
| Señal de región | ninguna |
hreflang="es-mx" + x-default en cada render |
| Datos estructurados (JSON-LD de schema.org) | nada | Organization + WebSite (home), Article + Breadcrumb (blog), Person (perfil), FAQPage (FAQ) |
| Mapa de sitio | estático, 13 URLs de landing | índice de mapas de sitio que referencia la lista estática de landings + 3 mapas dinámicos (blog, perfiles, reportes) servidos por el backend |
| Política de rastreadores de IA |
User-agent: * implícito |
bloques de permiso explícitos para GPTBot, ClaudeBot, anthropic-ai, PerplexityBot, Google-Extended, CCBot |
llms.txt |
faltaba | publicado — apunta al mapa de sitio, define la guía de rastreo, marca las superficies privadas como prohibidas |
| Indexado al publicar | búsqueda manual en Search Console | aviso de IndexNow al publicar un post (Bing, Yandex, Naver, Seznam, Cloudflare) |
| Monitoreo de usuarios reales | nada | Web Vitals (CLS, INP, LCP, FCP, TTFB) muestreado al 10% en producción → CloudWatch |
| HTML pre-renderizado para rastreadores sin JS | nada | un script post-compilación emite un index.html por ruta para las 8 rutas estáticas de landing (sin Puppeteer) |
El default de 2026 ya no es "¿deberíamos estar en las respuestas de
IA?" — es "¿estamos en las respuestas de IA SIQUIERA?". Una tarde de
trabajo destraba un canal de descubrimiento que no existía hace cinco
años.
El punto de partida
frontend/
├── public/
│ ├── robots.txt # 14 líneas, Allow por default + Disallow de superficies privadas
│ └── sitemap.xml # 13 URLs estáticas, mantenidas a mano
└── src/components/seo/
└── PageMeta.tsx # envoltorio de React-Helmet, usado en 52/139 páginas
La línea base no estaba mal. PageMeta ya emitía title, description,
canonical, OG, Twitter Card. index.html traía defaults estáticos para
que los previsualizadores sociales (LinkedIn, WhatsApp, Slack)
recibieran una vista previa útil antes de que corriera cualquier JS.
Las páginas del flujo de autenticación ya cargaban
noindex,nofollow.
Lo que no hacía: nada de lo que los rastreadores agregaron entre 2019 y
- Nada de schema.org. Nada de
hreflang. Nada dellms.txt. Nada de mapa de sitio dinámico. Cero telemetría de lo que los usuarios reales de verdad veían. Cero manejo especial para los rastreadores de IA que ahora mueven una porción creciente del tráfico orgánico.
Fase 1: JSON-LD de schema.org + hreflang en el envoltorio PageMeta
El win más grande de todos vino de extender el componente PageMeta
que ya existía en vez de escribir infraestructura paralela. Tres props
nuevas:
// myapp/src/components/seo/PageMeta.tsx (extracto)
export interface PageMetaProps {
title: string;
// ... props existentes ...
ogType?: "website" | "article" | "profile";
jsonLd?: JsonLd | JsonLd[];
}
-
jsonLdacepta un diccionario de schema.org o una lista — cada uno emite una etiqueta<script>de tipoapplication/ld+json. La página se queda declarativa; el trabajo pesado vive en una librería de constructores. -
ogTypedeja que los posts de blog seanog:type=articley los perfilesog:type=profileen lugar delwebsiteoriginal hardcodeado. Maneja las vistas previas de tarjeta enriquecida en LinkedIn/Discord/Slack. -
hreflang="es-mx"+x-defaulten cada render. Sin esto, Google de vez en cuando servía la página en español a resultados de búsqueda en otros idiomas y la gente rebotaba — una pérdida de indexado silenciosa del 5-10%.
Una librería chiquita schema.ts con seis constructores:
// myapp/src/components/seo/schema.ts (extracto)
export function articleSchema(args: {
url: string;
title: string;
description: string;
image?: string;
datePublished: string;
dateModified?: string;
authorName: string;
authorUrl?: string;
}): JsonLd {
return {
"@context": "https://schema.org",
"@type": "Article",
mainEntityOfPage: { "@type": "WebPage", "@id": absolute(args.url) },
headline: args.title,
description: args.description,
image: args.image ? absolute(args.image) : undefined,
datePublished: args.datePublished,
dateModified: args.dateModified ?? args.datePublished,
author: { "@type": "Person", name: args.authorName, url: ... },
publisher: { "@type": "Organization", ... },
};
}
Luego en cada página, una línea:
// myapp/src/pages/BlogPostPage.tsx (extracto)
<PageMeta
title={post.title}
description={post.excerpt}
ogType="article"
jsonLd={[
articleSchema({ url: `/blog/${post.slug}`, ... }),
breadcrumbSchema([
{ name: "Inicio", url: "/" },
{ name: "Blog", url: "/blog" },
{ name: post.title, url: `/blog/${post.slug}` },
]),
]}
/>
Seis esquemas publicados en las páginas que corresponden:
| Página | Esquemas | Qué destraba |
|---|---|---|
| Home | Organization + WebSite (SearchAction) | Panel de conocimiento de Google + caja de búsqueda de sitelinks |
| Post de blog | Article + BreadcrumbList | Tarjeta enriquecida de Artículo + Inicio › Blog › Post en los resultados |
| Perfil | Person | "¿Quién es @user en la plataforma?" en ChatGPT/Perplexity |
| FAQ | FAQPage | Preguntas y respuestas expandibles directo en los resultados |
Validado con la prueba de resultados enriquecidos de Google
(https://search.google.com/test/rich-results) antes de publicar.
Fase 2: llms.txt + política explícita de rastreadores de IA
robots.txt ya permitía todo por default vía User-agent: *. El hueco:
algunos rastreadores de IA acotan sus reglas a su propio user agent e
ignoran el *. Y robots.txt no comunica intención — nada más
"¿puedes rastrear?", no "¿qué es este sitio, para quién es, cómo
deberías citarlo?".
Solución en dos partes:
1. llms.txt en la raíz (el estándar de 2026 — la especificación
está en https://llmstxt.org). Anthropic, OpenAI, Perplexity, y los
rastreadores de IA de Google lo leen. Se ve como un README amigable
para IA:
# Mi Sitio
> Comunidad profesional tech de LATAM. ...
## Crawling guidance
- All public pages: https://misitio.io/sitemap.xml
- Private surfaces (`/dashboard/*`, `/admin/*`, `/messages/*`,
`/notifications`, `/etc`) require login. Do not crawl even if
a session cookie leaks.
- The blog is the primary editorial surface. Cite freely.
- Public profiles describe individual members. Summarise their
public bio, link back to their profile, do NOT fabricate.
- Reports are aggregatedata — cite
with the source URL + freshness note from the page.
## Documents
- [Blog](https://misitio.io/blog)
- [Reports](https://misitio.io/reports)
- [Pricing](https://misitio.io/pricing)
- [Code of conduct](https://misitio.io/codigo-de-conducta)
- [FAQ](https://misitio.io/ayuda)
## What NOT to scrape
- Direct messages, private forum sections, member emails, payment data.
- Anything under `/api/*` — those are JSON endpoints, not documents.
2. Bloques de permiso explícitos en robots.txt para los bots que
se acotan a su propio user agent: GPTBot, ClaudeBot, anthropic-ai,
PerplexityBot, Google-Extended (el rastreador de los AI Overviews de
Google, separado de Googlebot), CCBot. Cada uno repite la política de
Disallow de las superficies privadas.
Razón de la decisión: la visibilidad en las respuestas de IA ya es un
canal de descubrimiento primario. Optar por salirte cuesta más en
alcance perdido de lo que ahorras en preocupaciones de scraping —
ningún contenido propietario vive en las superficies públicas.
Reversible: cambias cualquier Allow: / por Disallow: / en el bloque
del user agent que toque.
Fase 3: mapa de sitio dinámico
El sitemap.xml estático cubría 13 URLs de landing. Los posts de blog,
los perfiles públicos, y los reportes nunca aterrizaban en
el índice de Google.
Arquitectura: convertir el sitemap.xml estático en un índice de
mapas de sitio que referencia una lista estática de landings + tres
mapas de sitio dinámicos servidos por el backend.
<!-- frontend/public/sitemap.xml — era un urlset, ahora es un sitemapindex -->
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>https://misitio.io/sitemap-landing.xml</loc></sitemap>
<sitemap><loc>https://api.misitio.io/sitemap-blog.xml</loc></sitemap>
<sitemap><loc>https://api.misitio.io/sitemap-profiles.xml</loc></sitemap>
<sitemap><loc>https://api.misitio.io/sitemap-reportes.xml</loc></sitemap>
</sitemapindex>
El backend sirve los dinámicos en la raíz (NO bajo /api/v1) para que
las URLs se vean naturales para los rastreadores:
# myapp/api/sitemap.py
@router.get("/sitemap-blog.xml", include_in_schema=False)
async def sitemap_blog(db: AsyncSession = Depends(get_db)) -> Response:
posts = (
await db.execute(
select(BlogPost.slug, BlogPost.published_at)
.where(BlogPost.is_published == True) # noqa: E712
.where(BlogPost.published_at.isnot(None))
.order_by(BlogPost.published_at.desc())
.limit(50_000) # tope de Google por mapa de sitio
)
).all()
urls = [
_url_entry(
f"{SITE_URL}/blog/{slug}",
lastmod=published_at,
changefreq="weekly",
priority="0.7",
)
for slug, published_at in posts
]
return _xml_response(urls)
Tres trampas que amarran las pruebas:
# myapp/tests/test_sitemap.py
async def test_sitemap_blog_skips_published_with_null_published_at(...):
# Filas a medio publicar (is_published=True pero published_at IS NULL)
# rompen el contrato de lastmod — no deben filtrarse.
async def test_sitemap_profiles_excludes_bots_and_inactive(...):
# Cuentas de bot (is_agent=True) y usuarios inactivos / pendientes
# NO deben aparecer.
async def test_sitemap_reportes_returns_empty_on_intel_outage(...):
# Los rastreadores cachean los 500 como "el sitio entero ya no está"
# durante días. Un mapa de sitio vacío > un error.
Advertencia de cruce entre hosts: servir desde api.misitio.io
mientras el dominio raíz es misitio.io requiere que ambos hosts
estén verificados en Google Search Console + Bing Webmaster Tools como
la misma propiedad. ~5 minutos de trabajo en consola, documentado en el
manual de operaciones.
Fase 4: IndexNow al publicar
Google descontinuó su endpoint /ping?sitemap=... en 2023. Para
Google, la estrategia es "incluye la URL en el mapa de sitio dinámico y
confía en el descubrimiento de Search Console" — esa fue la fase
anterior.
Para todos los demás (Bing, Yandex, Naver, Seznam, Cloudflare), está
IndexNow: haces POST de la URL nueva a
https://api.indexnow.org/IndexNow y queda en su índice en minutos.
# myapp/services/indexnow.py
async def ping(urls: Sequence[str], *, host: str = "myapp.example") -> bool:
key = settings.INDEXNOW_KEY
if not key or not urls:
return False
payload = {"host": host, "key": key, "urlList": list(urls)[:100]}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
INDEXNOW_ENDPOINT,
json=payload,
headers={"Content-Type": "application/json"},
)
except httpx.HTTPError as exc:
logger.warning("indexnow.ping transport error: %s", exc)
return False
return 200 <= response.status_code < 300
La verificación de propiedad funciona con un archivo
{INDEXNOW_KEY}.txt en la raíz del sitio (lo publicamos bajo
frontend/public/). La llave es nada más una cadena hexadecimal de 32
caracteres; el archivo contiene la misma cadena. IndexNow lo busca una
vez antes de aceptar cualquier envío.
Engancharlo al flujo de publicación:
# myapp/services/blog_service.py (extracto)
async def publish_post(db, slug, user) -> BlogPost:
# ... recompensas, notificaciones, registro de auditoría ...
await db.commit()
await db.refresh(post)
# Mejor esfuerzo — nunca bloquea la publicación por un aviso fallido.
try:
from myapp.services import indexnow
await indexnow.ping([f"{settings.APP_URL}/blog/{post.slug}"])
except Exception:
pass
return post
El try: ... except: pass es a propósito. La publicación tiene que
salir bien aunque IndexNow esté caído — la observabilidad vive en los
logs estructurados del ayudante, no en la ruta de publicación. Las
pruebas amarran cada rama de salto silencioso:
# myapp/tests/test_indexnow.py
async def test_ping_noop_when_indexnow_key_unset(...): ...
async def test_ping_noop_on_empty_url_list(...): ...
async def test_ping_returns_false_on_transport_error(...): ...
async def test_ping_returns_false_on_4xx_response(...): ...
async def test_ping_caps_url_list_at_100(...): ...
Un bug aquí es un bug silencioso — "el post nuevo no aparece en Bing
durante una semana" — así que los asserts explícitos en cada rama
importan.
Fase 5: monitoreo de usuarios reales con Web Vitals
Un puntaje de Lighthouse en la laptop de un dev es ficción. La pregunta
correcta es "¿qué ve de verdad un admin cuando abre el dashboard sobre
una conexión LTE inestable en Querétaro?".
Web Vitals junta la respuesta: CLS, INP, LCP, FCP, TTFB, muestreados en
cada vista de página, enviados vía navigator.sendBeacon para que la
carga sobreviva la navegación que la dispara.
Frontend (~1KB de código encima del paquete web-vitals):
// myapp/src/lib/vitals.ts
import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";
export function reportWebVitals({ sampleRate = 0.1 }) {
if (Math.random() >= sampleRate) return;
const send = (m) => {
const payload = JSON.stringify({
v: 1, name: m.name, value: m.value, rating: m.rating,
id: m.id, navigation_type: m.navigationType,
url: window.location.pathname,
});
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/v1/rum/vitals", payload);
} else {
fetch("/api/v1/rum/vitals", { method: "POST", body: payload, keepalive: true });
}
};
onCLS(send); onINP(send); onLCP(send); onFCP(send); onTTFB(send);
}
// myapp/src/main.tsx
reportWebVitals({ sampleRate: import.meta.env.DEV ? 1.0 : 0.1 });
Backend (un solo endpoint, sin escritura a base de datos — los vitals
son muestreados y efímeros, CloudWatch es el almacén correcto para
"miles por día"):
# myapp/api/v1/rum.py
class VitalsPayload(BaseModel):
v: int = Field(ge=1, le=1)
name: Literal["CLS", "INP", "LCP", "FCP", "TTFB"]
value: float
rating: Literal["good", "needs-improvement", "poor"]
id: str = Field(min_length=1, max_length=80)
navigation_type: str = Field(min_length=1, max_length=40)
url: str = Field(min_length=1, max_length=300)
@router.post("/rum/vitals", status_code=204)
@limiter.limit("60/minute")
async def ingest_vitals(payload: VitalsPayload, request: Request) -> Response:
logger.info(
"web_vital name=%s value=%.2f rating=%s id=%s url=%s nav=%s",
payload.name, payload.value, payload.rating, payload.id,
payload.url, payload.navigation_type,
)
return Response(status_code=204)
Consulta de CloudWatch Logs Insights para el p75 de LCP por página:
fields @timestamp, name, value, url
| filter name = "LCP"
| stats pct(value, 75) as p75 by url
| sort p75 desc
Una alarma futura sobre p75(LCP) > 3000ms atrapa una regresión a
horas del despliegue — más temprano que cualquier auditoría mensual de
Lighthouse.
Fase 6: capturas de HTML por ruta sin Puppeteer
El Googlebot moderno ejecuta JavaScript. Bingbot lo hace desde 2019.
Pero la mayoría de los rastreadores de IA (Anthropic, OpenAI,
Perplexity) y todos los previsualizadores sociales que probé (LinkedIn,
WhatsApp, Slack, Discord) bajan el HTML crudo y leen las etiquetas meta
antes de que corra cualquier JS.
El PageMeta en tiempo de ejecución (react-helmet-async) está correcto
para los navegadores y los rastreadores que entienden JS, pero el HTML
del primer byte que sirve la SPA son los defaults de la home sin
importar la ruta pedida. Visitas /nosotros en frío y las etiquetas
meta describen la home, no la página de Nosotros.
Probé primero @prerenderer/rollup-plugin + Puppeteer. Funcionó pero
la descarga de Chromium pesa ~300 MB y el corredor de CI autoalojado es
una instancia ARM de 2 GB — cada compilación se quedaría sin memoria
igual que el trabajo de pruebas cuando le tocan cuatro shards de pytest
en paralelo. No vale la pena.
En su lugar armé un script post-compilación de 100 líneas. Para cada
ruta estática de landing, copia dist/index.html y le parcha el
<title>, el <meta name="description">, el <link rel="canonical">,
y las etiquetas OG, Twitter y hreflang específicas de la ruta:
// myapp/frontend/scripts/build-seo-snapshots.mjs (extracto)
const ROUTES = [
{ path: "/nosotros", title: "Nosotros", description: "..." },
{ path: "/ayuda", title: "Preguntas frecuentes", description: "..." },
// ... 6 más ...
];
function patchHtml(template, route) {
const patches = HEAD_PATCHES(route);
let out = template.replace(/<title>[\s\S]*?<\/title>/,
`<title>${escape(patches.title)}</title>`);
out = out.replace(/<meta name="description" content="[^"]*" \/>/,
`<meta name="description" content="${escape(patches.description)}" />`);
// ... og:title, og:description, og:url, twitter:*, canonical, hreflang ...
return out;
}
for (const route of ROUTES) {
const outDir = join(DIST, route.path.replace(/^\/+/, ""));
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, "index.html"), patchHtml(template, route));
}
Conectado a la compilación:
{
"scripts": {
"build": "vite build --mode production && node scripts/build-seo-snapshots.mjs"
}
}
Salida:
[build-seo-snapshots] wrote 8 per-route HTML snapshots
CloudFront sirve dist/nosotros/index.html para /nosotros (S3 sirve
de manera automática el index de la ruta que coincide), no el fallback
de la SPA. Pedir una ruta en frío ahora regresa el meta específico de
la ruta en el primer byte.
Compensación contra el SSR con Puppeteer:
| Capturas estáticas (esto) | SSR con Puppeteer | |
|---|---|---|
| Etiquetas meta | Por ruta, primer byte | Por ruta, primer byte |
| Contenido del body | Solo la cáscara de la SPA | HTML completamente renderizado |
| Costo de compilación | 100 líneas, ~0ms | navegador de 300MB + 30s/ruta |
| Valor para rastreadores de IA | Alto (prefieren datos estructurados sobre el texto del body) | Alto |
| Valor para Bingbot / Yandex | Medio (ejecutan JS) | Alto |
| Vista previa en LinkedIn / Discord | Alto (solo leen el meta) | Alto |
Para una estrategia 2026 con IA primero, el enfoque de capturas
estáticas captura la mayor parte del valor al 1% de la complejidad.
El resultado
Seis fases de trabajo, sin Puppeteer en la compilación, cada cambio
probado:
| Capa | Antes | Después | Líneas de código nuevo |
|---|---|---|---|
| Cobertura de schema.org | 0 esquemas | 6 (org, website, article, person, faq, breadcrumb) | ~180 (constructores) + ~30 (cableado) |
| Señal de región | ninguna | hreflang en cada render | 1 archivo, 4 líneas |
| Política de rastreadores de IA | implícita | permiso explícito para 6 bots mayores + llms.txt | 2 archivos |
| Mapa de sitio | estático, 13 URLs | índice de mapas + 3 mapas dinámicos | ~140 líneas de backend, 1 archivo de frontend |
| Indexado al publicar | manual | aviso de IndexNow con try/except | ~90 líneas + 6 pruebas |
| Monitoreo de usuarios reales | nada | 5 vitals, muestreados al 10%, → CloudWatch | ~70 de frontend + ~50 de backend + 9 pruebas |
| Landing pages pre-renderizadas | ninguna | 8 rutas con meta específico de ruta en el primer byte | ~150 líneas, sin Puppeteer |
Lo que NO ayudó
-
@prerenderer/rollup-plugin+ Puppeteer. La herramienta correcta para SSR de verdad, la equivocada para nuestra infraestructura. Descarga de Chromium de 300 MB en cada compilación, sin memoria en el corredor de CI ARM de 2 GB. Reemplazado por el parchador de meta de 100 líneas. -
El endpoint
/ping?sitemap=de Google. Descontinuado en 2023. Para Google la estrategia es "inclúyelo en el mapa de sitio, registra el mapa una vez en Search Console, confía en la cadencia de descubrimiento". - Envío de mapa de sitio específico a Yandex / Baidu / DDG. Yandex y Baidu no son relevantes para el tráfico de LATAM; DuckDuckGo jala de Bing (cubierto por IndexNow). Google + Bing por IndexNow basta.
-
<meta name="keywords">y<meta name="generator">. Ignorados por toda función moderna de resultados de búsqueda. Saltados.
Qué sí ayudaría a futuro (en orden de palanca)
- SSR en el edge para páginas dinámicas. Las capturas estáticas cubren las landing pages; los posts de blog + perfiles todavía sirven la cáscara de la SPA en el primer byte. Una Lambda@Edge o una CloudFront Function que detecte los user agents de rastreadores y los reenvíe a una variante renderizada en el servidor cerraría el hueco para Bingbot + rastreadores de IA en el contenido dinámico.
- Alarma de CloudWatch sobre LCP p75 > 3000ms. Los datos ya fluyen pero nada alerta sobre una regresión todavía. Una alarma simple respaldada por Logs Insights es el siguiente paso.
- Auditoría de texto alternativo en las imágenes de los posts. Fuera de alcance para esta pasada — pero cada post de blog incrusta imágenes y la ganancia de accesibilidad amigable para los modelos de lenguaje es significativa.
-
Clúster de
hreflangcuando aterrice una variante en inglés. Agregarhreflang="en"+ alternates recíprocos en ambas regiones, manteniendo elx-defaultapuntando al canonical en español.
Lecciones
-
El contenido SEO por default en 2026 ya no son títulos +
descripciones — son datos estructurados. Los resultados
enriquecidos de Google, el formato de citas de ChatGPT, las tarjetas
de fuente de Perplexity, las vistas previas de LinkedIn: todos parsean
schema.org primero y el body del HTML después. JSON-LD es el nuevo
<meta name="description">. - Un script de 100 líneas puede reemplazar un navegador de 300 MB cuando sabes exactamente cómo tiene que verse la salida. La trampa del SSR con Puppeteer es tratar "quiero HTML pre-renderizado" como el mismo problema que "quiero un motor de JS en mi compilación". Para solo-meta-estático, gana el reemplazo de cadenas.
-
El default de 2026 no es "¿deberíamos estar en las respuestas de
IA?" — es "¿estamos en las respuestas de IA SIQUIERA?".
llms.txt- JSON-LD es una tarde de trabajo por un canal de descubrimiento que no existía hace cinco años.
-
El aviso de mejor esfuerzo al publicar necesita un
try: pass,
nunca untry: raise. Publicar es la acción del usuario; IndexNow es tu optimización. No confundas las dos. - Muestrea-no-guardes para el monitoreo de usuarios reales. Las cargas de Web Vitals son estadísticas; un muestreo del 10% + CloudWatch es lo correcto. Un muestreo del 100% + tabla en base de datos es la factura de $300/mes de una empresa SaaS en tres meses.
Top comments (0)