DEV Community

Cover image for SEO en 2026, parte 2: la captura rankea — ahora haz que convierta

SEO en 2026, parte 2: la captura rankea — ahora haz que convierta

El post anterior terminó donde terminan la mayoría de los posts de "ya hicimos SEO": la página es indexable.

Dos cosas se rompen justo después de esa línea de meta, y ninguna aparece en un puntaje de Lighthouse:

  1. La captura rankea pero no convierte nada. El HTML que el rastreador indexó y el HTML al que aterriza un humano sin sesión, tiene el contenido pero ninguna llamada a la acción, porque la llamada a la acción vive en el paquete de JavaScript que el rastreador nunca corrió. Rankeaste para la búsqueda y luego no dijiste nada.
  2. El siguiente despliegue des indexa la página durante unos minutos. Una SPA con hash de contenido que borra sus fragmentos viejos al desplegar le entrega a cada pestaña abierta y a cada re rastreo a media propagación, un 404 sobre un módulo que todavía espera. La página que rankeaba ayer hoy lanza Failed to fetch dynamically imported module.

TL;DR

Capa Antes (fin de la parte 1) Después
CTA en un reporte indexado renderizado solo por la SPA — ausente de la captura que lee el rastreador y del primer pintado sin sesión renderizado en el servidor dentro de la captura, derivado de los datos de la propia página, indexable
Texto del CTA / enlaces de destino fijos en el código → un re-despliegue para cambiar una palabra configuración en tiempo de ejecución en una bolsa de ajustes JSONB; editable desde el admin, sin re-despliegue; los pre-renderizados se refrescan al guardar
CTA por audiencia una sola variante anónimo → unirse; autenticado → enlaces directos al siguiente paso
Despliegue de assets aws s3 sync --delete — los fragmentos viejos con hash se borran en el instante en que aterriza el paquete nuevo sync aditivo (sin --delete); los fragmentos viejos + nuevos coexisten; una pestaña abierta sigue funcionando
Limpieza del paquete viejo implícita (el delete) regla explícita de ciclo de vida de S3 — assets/ expira 30 días después de que un paquete deja de servirse
Invalidación de CloudFront "/*" (desperdicio; los assets son inmutables) solo "/index.html" — el único objeto que cambia un despliegue
Falla al cargar un fragmento la pantalla roja de error de React Router vite:preloadError → una sola recarga acotada hacia el paquete fresco

El hilo conductor: una captura pre-renderizada es necesaria pero no
suficiente.
La parte 1 hizo que la captura existiera. La parte 2 hace
que haga su trabajo y que sobreviva al siguiente git push.

El punto de partida

La fase 6 de la parte 1 nos dejó con un parchador de HTML por ruta: para
cada reporte público, renderiza su markdown a HTML sanitizado del lado
del servidor, lo inyecta dentro del <div id="root"> de la cáscara de la
SPA, parcha el <title> / description / canonical, y agrega el JSON-LD de
Article. Los rastreadores leen el HTML inyectado; la SPA en vivo hidrata
encima de él al montarse.

myapp/backend/app/services/report_seo.py   # markdown → HTML + JSON-LD, inyectado en la cáscara
myapp/backend/app/services/prerender.py     # parche genérico del head + inyección en #root
myapp/backend/app/jobs/render_reports.py    # itera los reportes publicados, escribe un index.html por ruta
Enter fullscreen mode Exit fullscreen mode

Funcionó. Los reportes rankearon. Luego vimos qué recibía de verdad el visitante que rankeaba, y qué le hacía el siguiente despliegue.

Fase 1: la trampa del CTA solo-en-la-SPA

La página de reporte es un imán de tráfico orgánico buenísimo: una tabla de datos que la gente busca por nombre. Así que agregamos un bloque de conversión al final, "así es como puedes actuar sobre esto", como un componente de React en la ruta del reporte. Lo publicamos, se veía increíble en el navegador.

Luego: le haces curl a la URL pública como la ve un rastreador.

curl -s https://myapp.example/reports/<slug> | grep -c "Quieres llegar"
# 0
Enter fullscreen mode Exit fullscreen mode

El CTA no estaba en ningún lado del HTML entregado. Obvio en
retrospectiva — el pre-renderizado (report_seo.py) renderiza el cuerpo
del markdown
dentro de la captura; el CTA era un componente de React,
montado solo después de que carga el paquete. Entonces:

  • El rastreador indexó el cuerpo del reporte y nunca vio el CTA. La captura que cacheó para los motores de respuestas de IA es un callejón sin salida.
  • El humano sin sesión — la mayoría del tráfico orgánico, vio el CTA un instante tarde (después de la hidratación), y si rebotaba durante la carga del JS, nunca.

Un CTA que solo existe del lado del cliente es un CTA que no existe para exactamente la audiencia que una captura está hecha para servir. La solución es renderizarlo dentro de la captura, igual que el cuerpo — pero una cadena estática pegada en cada reporte es débil. La versión interesante está derivada de los datos de la propia página.

Fase 2: un CTA construido con los datos de la propia página

Cada reporte ya calcula un agregado ordenado antes de escribirse, las filas de las que el reporte trata. Habíamos estado tirando esa
estructura después de generar la prosa. En lugar de eso, conserva el
top-N y persístelo junto al reporte (una columna JSONB key_findings sin usar ya estaba en el modelo, sin migración):

# myapp/backend/app/agents/report/generator.py (extracto)
def _top_findings(rows: list[dict], limit: int = 3) -> list[dict]:
    """Top-N de filas para el CTA de conversión. Deduplicado por categoría
    canónica para que el CTA muestre variedad, no la misma tres veces.
    Persistido en ``Report.key_findings`` y leído por el pre renderizado ypor la SPA.
    """
    valid = [r for r in rows if r.get("value")]
    valid.sort(key=lambda r: r["value"], reverse=True)
    seen, top = set(), []
    for r in valid:
        cat = canonical_category(r["category"])
        if cat in seen:
            continue
        seen.add(cat)
        top.append({"category": cat, "label": label_for(cat),
                    "region": r["region"], "value": r["value"]})
        if len(top) >= limit:
            break
    return top
Enter fullscreen mode Exit fullscreen mode

Luego el pre renderizado crece un bloque de CTA, inyectado en la captura entre el cuerpo y el footer, pura construcción de cadenas, sin navegador:

# myapp/backend/app/services/report_seo.py (extracto)
def _render_cta(cta_config: dict | None, findings: list[dict] | None) -> str:
    if not cta_config or not cta_config.get("enabled", True):
        return ""
    hook = ""
    if findings:
        t = findings[0]
        hook = (
            '<p class="cta-hook">This month, '
            f"<strong>{escape(t['label'])}</strong> leads in "
            f"{escape(region_name(t['region']))} at "
            f"<strong>${int(t['value']):,}</strong>.</p>"
        )
    items = "".join(
        f'<li><a href="{escape(s["route"])}">{escape(s["label"])}</a>'
        + (f"{escape(s['blurb'])}" if s.get("blurb") else "")
        + "</li>"
        for s in cta_config.get("services", []) if s.get("label")
    )
    return (
        '<aside class="report-cta">'
        f"{hook}"
        f"<h2>{escape(cta_config['headline'])}</h2>"
        f"<p>{escape(cta_config['subcopy'])}</p>"
        f"<ul>{items}</ul>"
        f'<p><a class="btn" href="{escape(cta_config['join_route'])}">'
        f"{escape(cta_config['join_label'])}</a></p>"
        "</aside>"
    )
Enter fullscreen mode Exit fullscreen mode

La línea del gancho, "This month, X leads at $N" ,cambia por reporte y por mes, calculada desde los datos para los que el reporte ya rankea. El rastreador la indexa. El visitante sin sesión la ve en el primer pintado.

Ahora curl regresa 1.

Las pruebas amarran la forma, todas puras (sin base de datos, sin red):
test_report_page_renders_indexable_cta (el gancho + un enlace de
servicio real + el botón de unirse, todos aterrizan dentro del
<div id="root">), test_report_page_cta_disabled_renders_nothing,
test_report_page_cta_without_top_data_still_shows_block (un reporte
heredado sin key_findings igual recibe la propuesta de valor, nada más sin gancho), y test_report_page_no_cta_config_is_backward_compatible (el
pre renderizado llamado a la antigua, sin argumento de CTA, igual
renderiza).

Fase 3: editable sin re despliegue

Un CTA fijo en el código significa un despliegue para arreglar una errata.
Peor, los enlaces a los que apunta el CTA son decisiones de producto que cambian más rápido que el código. Así que el texto, las rutas de destino, y el interruptor de encendido/apagado viven en una configuración de tiempo de ejecución la misma bolsa de ajustes JSONB por agente que el resto del admin ya usaba.

# myapp/backend/app/services/report_cta_config.py (extracto)
DEFAULTS = {
    "enabled": True,
    "headline": "Want to get there?",
    "subcopy": "Here's how to close the gap:",
    "join_label": "Join", "join_route": "/register",
    "services": [ ... ],   # [{key, label, route, blurb}, ...]
}

async def resolve_cta_config(db) -> CtaConfig:
    row = await _load(db)                      # una sola búsqueda por PK
    overrides = dict(row.config_json or {}) if row else {}
    return CtaConfig(**{k: overrides.get(k, v) for k, v in DEFAULTS.items()})
Enter fullscreen mode Exit fullscreen mode

El endpoint GET es público — es texto y rutas internas, sin secretos, así que la SPA y el pre renderizado leen la misma configuración resuelta.
El PATCH está protegido para admin y validado. La única regla que vale la pena imponer en la orilla: cada enlace es una ruta interna.

def _clean_route(key, raw):
    value = _clean_str(key, raw)
    if not value.startswith("/"):
        raise HTTPException(400, detail=f"{key} must be an internal route (/...)")
    return value
Enter fullscreen mode Exit fullscreen mode

Sin ese chequeo, "edita el CTA" se vuelve "pega un https:// en
cualquier lado de tus páginas indexadas de mayor tráfico", un punto de apoyo de redirección abierta / enlace fuera del sitio en exactamente las superficies en las que más confían los rastreadores.

Pruebas:
test_validate_cta_config_rejects_external_route,
test_cta_config_patch_merges_and_persists (el PATCH se fusiona sobre los
defaults, no aplasta las llaves que no tocó), test_cta_config_reset,
test_cta_config_defaults_when_unset.

Un detalle de cableado que importa para el SEO: un cambio de configuración tiene que refrescar las capturas, o el rastreador sigue viendo el CTA viejo. El CTA está en cada reporte, así que el PATCH del admin dispara un re renderizado en segundo plano de todas las páginas de reporte publicadas no solo la que se está editando.

# myapp/backend/app/api/v1/admin_reports.py (extracto)
@router.patch("/reports/cta-config")
async def update_cta(body, background, _admin = Depends(require_admin)):
    result = await client.update_cta_config(body.config)
    background.add_task(prerender_publish.refresh_all_reports)  # re-captura todos
    return result
Enter fullscreen mode Exit fullscreen mode

Fase 4: el espejo en la SPA, anónimo vs autenticado

El CTA de la captura es la variante anónima — eso es lo que son un
rastreador y un visitante sin sesión. Pero una vez que la SPA hidrata para un miembro con sesión, el mismo bloque debe hacer algo distinto: no "unirse", sino enlaces directos al siguiente paso. Misma configuración, dos renderizados.

// myapp/frontend/src/components/report/ReportCTA.tsx (extracto)
export default function ReportCTA({ topFindings }: Props) {
  const { data: cfg } = useReportCtaConfig();
  const { isAuthenticated } = useAuth();
  if (!cfg || !cfg.enabled) return null;

  const top = topFindings?.[0] ?? null;
  return (
    <aside className="report-cta">
      {top && (
        <p>This month, <strong>{top.label}</strong> leads in{" "}
          {regionName(top.region)} at <strong>${top.value.toLocaleString()}</strong>.</p>
      )}
      <h2>{cfg.headline}</h2>
      <ul>
        {cfg.services.map((s) =>
          isAuthenticated
            ? <li key={s.key}><Link to={s.route}>{s.label}</Link>{s.blurb && ` — ${s.blurb}`}</li>
            : <li key={s.key}><span>{s.label}</span>{s.blurb && ` — ${s.blurb}`}</li>,
        )}
      </ul>
      {isAuthenticated
        ? <Link className="btn" to={cfg.services[0]?.route}>Continue</Link>
        : <Link className="btn" to={cfg.join_route}>{cfg.join_label}</Link>}
    </aside>
  );
}
Enter fullscreen mode Exit fullscreen mode

El componente lee topFindings directo de la carga del reporte (el mismo key_findings que usó el pre-renderizado), sin una segunda petición, sin recalcular del lado del cliente. Las pruebas cubren los tres estados: anónimo muestra el botón de unirse y los servicios como texto plano; autenticado muestra los servicios como enlaces más una acción primaria de "continuar"; la configuración deshabilitada no renderiza nada.

Eso cierra el hueco de conversión. La otra falla estaba esperando en el CI.

Fase 5: el despliegue que des indexa tu página

Esta la atrapamos con el monitoreo de usuarios reales de la parte 1. El recolector de Web Vitals de la fase 5 del primer post también muestrea los errores no atrapados, y después de cada despliegue de frontend había una ráfaga chica y confiable en el canal de errores:

TypeError: Failed to fetch dynamically imported module:
https://myapp.example/assets/ReportPage-BuE6Rmpx.js
Enter fullscreen mode Exit fullscreen mode

Siempre un fragmento de ruta, siempre en los minutos justo después de un despliegue, siempre un hash que ya no existía. La causa raíz era una sola bandera en el despliegue:

# antes — myapp/.github/workflows/deploy-frontend.yml
aws s3 sync frontend/dist s3://$BUCKET --cache-control "...immutable" --delete
Enter fullscreen mode Exit fullscreen mode

--delete quita cada objeto que no está en el dist/ nuevo, incluyendo los fragmentos con hash de la compilación anterior. Secuencia:

  1. Un visitante carga la app. Su pestaña guarda un index.html que referencia ReportPage-BuE6Rmpx.js.
  2. Aterriza un despliegue. Compilación nueva → ReportPage-OTHERHASH.js. El sync borra ReportPage-BuE6Rmpx.js.
  3. El visitante hace clic hacia una ruta diferida → la SPA importa el fragmento que todavía recuerda → 404Failed to fetch dynamically imported module → la pantalla roja de error de React Router.

Y no son solo las pestañas abiertas: un rastreador que vuelve a pedir la URL en vivo durante la ventana de propagación de CloudFront puede hidratar contra un paquete reemplazado a medias. La página que rankeaba está, por esos minutos, rota.

Los headers de caché ya estaban bien — los assets con hash immutable por un año, el index.html en no-cache. El bug era puramente que los assets viejos desaparecían. Los paquetes con hash de contenido están direccionados por contenido: el viejo y el nuevo pueden y deben coexistir.

Solución 1, la cura: desplegar los assets de manera aditiva. Quita el --delete.

# después — aditivo: los fragmentos con hash viejos + nuevos coexisten
aws s3 sync frontend/dist s3://$BUCKET --cache-control "...immutable"
Enter fullscreen mode Exit fullscreen mode

Solución 2, la limpieza: una regla de ciclo de vida de S3 para que "nunca borrar" no signifique "acumular por siempre". Los paquetes
reemplazados envejecen 30 días después de que dejan de servirse mucho después de cualquier sesión en vivo, lo bastante corto como para no amontonarse.

// myapp/infra/lib/frontend-stack.ts (extracto)
new s3.Bucket(this, "SpaBucket", {
  // ...
  lifecycleRules: [{
    id: "expire-old-build-assets",
    prefix: "assets/",                       // solo el paquete con hash
    expiration: cdk.Duration.days(30),
  }],
});
Enter fullscreen mode Exit fullscreen mode

Solución 3, el recorte de costo: angostar la invalidación.
index.html es el único objeto que cambia un despliegue (está en
no-cache, así que la orilla lo revalida). Los assets con hash son
inmutables, invalidar /* pagaba por desalojar entradas de caché que nunca pueden quedar viejas.

# antes: --paths "/*"      # desperdicio; los assets son inmutables
# después:
aws cloudfront create-invalidation --distribution-id "$ID" --paths "/index.html"
Enter fullscreen mode Exit fullscreen mode

Prueba de que funcionó, directo del bucket después del primer despliegue aditivo, el mismo fragmento de dos compilaciones, lado a lado:

$ aws s3 ls s3://$BUCKET/assets/ | grep ReportPage
ReportPage-BuE6Rmpx.js   # la compilación que recuerda la pestaña abierta
ReportPage-CF69kraa.js   # la compilación que acaba de salir
Enter fullscreen mode Exit fullscreen mode

El import de la pestaña abierta ahora resuelve a un 200, no a un 404.

Fase 6: recuperación del lado del cliente para la ventana de propagación

Los despliegues aditivos arreglan la causa. Pero una pestaña dejada
abierta más tiempo que el ciclo de vida de 30 días, o una carrera dura durante la propagación, todavía puede perder un fragmento, y "raro" en una página de alto tráfico es "diario" en términos absolutos. Así que la segunda capa es la recuperación: Vite dispara un evento vite:preloadError cuando un import dinámico falla. Atrápalo y recarga una vez hacia el index.html fresco en lugar de mostrar la pantalla de error.

La única sutileza es no entrar en bucle por siempre si el fragmento de verdad ya no está (sin conexión, expirado): acótalo.

// myapp/frontend/src/lib/chunkReload.ts (extracto)
const KEY = "chunkReload", MAX = 2, WINDOW_MS = 60_000;

export function installChunkReloadHandler(win: Window = window): void {
  win.addEventListener("vite:preloadError", (event) => {
    event.preventDefault();                 // suprime el re-lanzamiento default de Vite
    const now = Date.now();
    let s = readState(win.sessionStorage);
    if (now - s.t > WINDOW_MS) s = { t: now, n: 0 };   // reinicia tras una ventana tranquila
    if (s.n >= MAX) return;                 // ya le dimos nuestros intentos; deja que se muestre el error
    win.sessionStorage.setItem(KEY, JSON.stringify({ t: now, n: s.n + 1 }));
    win.location.reload();
  });
}
Enter fullscreen mode Exit fullscreen mode

A lo mucho dos recargas en una ventana de 60 segundos, luego se rinde y deja que la frontera de error renderice, así una caída real no se vuelve un bucle de recargas, pero un despliegue es invisible. Pruebas:
installChunkReloadHandler reloads once on the first preloadError,
stops after 2 reloads inside the window, y
recovers again after the quiet window resets.

El resultado

  • Las capturas de los reportes ahora cargan un CTA indexable y derivado de los datos. El HTML cacheado del rastreador y el primer pintado sin sesión tienen ambos un siguiente paso, la misma superficie que rankea ahora también convierte, sin dependencia del JS del cliente.
  • Los despliegues de frontend dejaron de producir la ráfaga de errores post despliegue en el monitoreo de usuarios reales. La métrica que publicamos en la parte 1 es cómo encontramos el bug y cómo confirmamos la solución, el canal de errores se quedó callado a través de los despliegues.
  • Editar el CTA es un cambio de configuración, no un despliegue, y re captura las páginas públicas de manera automática para que los rastreadores no se queden atrás.

Lo que NO ayudó

  • Renderizar en el servidor toda la pila del CTA. Tienta echar mano del SSR/SSG en el momento en que sale "el rastreador no ve mi componente". Pero el flujo de capturas de la parte 1 ya produce HTML rastreable; el CTA es una cadena más inyectada en él. El SSR habría sido una migración de framework para resolver un += cta_html.
  • Invalidar /* "por si las dudas". Nunca arregló el bug del fragmento (los assets estaban borrados, no viejos) y facturaba por desalojar objetos inmutables. El bug estaba en el bucket, no en la caché.

Lecciones

  • Una captura pre-renderizada es necesaria, no suficiente. El win de la parte 1 fue hacer que la captura existiera. Si la capa de conversión vive solo en el paquete, rankeaste para la búsqueda y luego no le dijiste nada a exactamente la audiencia que la captura sirve.
  • Deriva el CTA de los datos de la propia página. Un CTA estático es un re despliegue y un genérico. Los datos para los que la página ya rankea la fila de arriba, el número del titular, son el gancho más relevante que tienes, y es gratis.
  • Los assets con hash de contenido se tienen que desplegar de manera aditiva. --delete en una SPA con división de código es una caída autoinfligida en cada despliegue. Los fragmentos están direccionados por contenido; deja que el viejo y el nuevo coexistan y deja que una regla de ciclo de vida haga la limpieza con un retraso de 30 días, no de cero segundos.
  • Invalida la única cosa que cambió. index.html está en no-cache y es el único objeto mutable del despliegue. /* es un reflejo que cuesta dinero para desalojar cachés que son inmutables por construcción.
  • Acota tu recuperación. Una recarga ante error de fragmento es la red de seguridad correcta, pero una sin límite convierte una caída real en un bucle de recargas. Dos intentos en una ventana, luego saca el error.
  • La métrica que publicaste el trimestre pasado es cómo encuentras el bug de este trimestre. El monitoreo de usuarios reales de la parte 1 existía para vigilar los Web Vitals; atrapó una regresión de despliegue que nadie estaba buscando. La telemetría se acumula.

Top comments (0)