DEV Community
Grade 9
8d ago
SEO en 2026, cuando la mitad de tu tráfico llega por ChatGPT
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 de salarios) 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 de llms.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 []; } jsonLd acepta un diccionario de schema.org o una lista — cada uno emite una etiqueta <script> de tipo application/ld+json . La página se queda declarativa; el trabajo pesado vive en una librería de constructores. ogType deja que los posts de blog sean og:type=article y los perfiles og:type=profile en lugar del website original hardcodeado. Maneja las vistas previas de tarjeta enriquecida en LinkedIn/Discord/Slack. hreflang="es-mx" + x-default en 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 " , ... }, }; } Lue
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 de salarios) 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[]; } - jsonLd acepta un diccionario de schema.org o una lista — cada uno emite una etiqueta de tipoapplication/ld+json . La página se queda declarativa; el trabajo pesado vive en una librería de constructores. - ogType deja que los posts de blog seanog:type=article y los perfilesog:type=profile en lugar delwebsite original hardcodeado. Maneja las vistas previas de tarjeta enriquecida en LinkedIn/Discord/Slack. - hreflang="es-mx" +x-default en 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) 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 de salarios 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. https://misitio.io/sitemap-landing.xml https://api.misitio.io/sitemap-blog.xml https://api.misitio.io/sitemap-profiles.xml https://api.misitio.io/sitemap-salarios.xml 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_salarios_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 oper
Comments
No comments yet. Start the discussion.