En las aplicaciones de página única clásicas (Single Page Applications, SPA) construidas con React, el navegador del cliente recibe inicialmente un documento HTML minimalista: un contenedor vacío <div id="root"></div> y un enlace al bundle de JavaScript. Para los usuarios finales, el renderizado de la interfaz se lleva a cabo en el cliente (Client-Side Rendering, CSR). Sin embargo, para los motores de búsqueda y los rastreadores de redes sociales, esta arquitectura genera una barrera crítica de indexación.
El robot de búsqueda Googlebot utiliza un subsistema especializado denominado Web Rendering Service (WRS). La indexación de sitios CSR en Google ocurre en dos fases diferenciadas:
Asimismo, los bots de búsqueda como Yandex, Baidu y Naver, junto con los rastreadores de previsualización social (crawlers OpenGraph de Telegram, Twitter/X, LinkedIn, Discord y WhatsApp), poseen entornos de ejecución de JavaScript muy restringidos o no ejecutan scripts en absoluto. Si no se entrega HTML completamente renderizado en la primera respuesta HTTP, los enlaces carecen de vista previa y las páginas desaparecen del índice de búsqueda.
La arquitectura de Pre-rendering (renderizado dinámico) resuelve este problema sin exigir la reescritura completa de la base de código de la SPA hacia frameworks SSR como Next.js o Remix. Un Reverse Proxy intercepta las solicitudes entrantes: los usuarios humanos reciben el bundle estándar de la SPA, mientras que las solicitudes de bots verificados se redirigen de forma transparente a un pool de navegadores headless (Puppeteer, Playwright o Prerender.io) que genera y almacena en caché snapshots HTML completos de las páginas.
En el núcleo del pre-renderizado se encuentra la segregación de flujos de tráfico en la pasarela de red (Reverse Proxy, CDN o Edge Worker). El sistema determina el tipo de cliente mediante la cabecera User-Agent y heurísticas internas, derivando la solicitud por el circuito correspondiente.
+-----------------------------------------------------------------------+
| Solicitud HTTP Entrante |
+-----------------------------------------------------------------------+
|
v
+-----------------------------------------------------------------------+
| Reverse Proxy / CDN (Cloudflare / Nginx) |
| Análisis de User-Agent y flag de caché previa |
+-----------------------------------------------------------------------+
| |
[Usuario Estándar] [Bot / Rastreador SEO]
| |
v v
+---------------------------+ +---------------------------------+
| Hosting Estático (S3) | | Caché de Snapshots (Redis/S3) |
| Entrega index.html + JS | +---------------------------------+
+---------------------------+ |
| +---------+---------+
v | |
+---------------------------+ [Cache Hit] [Cache Miss]
| Navegador Cliente (CSR) | | |
| Hidratación y JS | v v
+---------------------------+ +----------------+ +----------------+
| Entrega HTML | | Pool Headless |
| listo | | (Puppeteer) |
| (TTFB < 80ms) | | Renderiza DOM |
+----------------+ +----------------+
|
+---------+
v
[Escritura en Redis/S3]
|
v
[Entrega HTML al Bot]Entidades clave de la arquitectura:
El router inspecciona el encabezado User-Agent. La lista de firmas abarca bots de Google, Yandex, Bing, redes sociales y motores de IA (Perplexity, GPTBot). Es fundamental emitir Vary: User-Agent para que la CDN jamás entregue una copia almacenada de bot a un visitante humano.
interface Env {
PRERENDER_SERVICE_URL: string;
PRERENDER_TOKEN: string;
CACHE_KV: KVNamespace;
}
const BOT_USER_AGENTS: readonly RegExp[] = [
/googlebot/i,
/yandex/i,
/bingbot/i,
/baiduspider/i,
/twitterbot/i,
/facebookexternalhit/i,
/linkedinbot/i,
/slackbot/i,
/telegrambot/i,
/whatsapp/i,
/chatgpt-user/i,
/gptbot/i,
/perplexitybot/i,
];
const IGNORED_EXTENSIONS: readonly string[] = [
'.js', '.css', '.xml', '.less', '.png', '.jpg', '.jpeg',
'.gif', '.pdf', '.svg', '.ico', '.woff', '.woff2', '.ttf',
];
function isBot(userAgent: string | null): boolean {
if (!userAgent) return false;
return BOT_USER_AGENTS.some((pattern) => pattern.test(userAgent));
}
function isStaticAsset(url: URL): boolean {
const pathname = url.pathname.toLowerCase();
return IGNORED_EXTENSIONS.some((ext) => pathname.endsWith(ext));
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const userAgent = request.headers.get('User-Agent');
// Omitir pre-renderizado para recursos estáticos y usuarios interactivos
if (isStaticAsset(url) || !isBot(userAgent)) {
const response = await fetch(request);
const newHeaders = new Headers(response.headers);
newHeaders.append('Vary', 'User-Agent');
return new Response(response.body, {
status: response.status,
headers: newHeaders,
});
}
// Verificar presencia de snapshot en Edge KV
const cacheKey = `snapshot:${url.origin}${url.pathname}${url.search}`;
const cachedHtml = await env.CACHE_KV.get(cacheKey);
if (cachedHtml) {
return new Response(cachedHtml, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'X-Prerender-Cache': 'HIT',
'Vary': 'User-Agent',
'Cache-Control': 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800',
},
});
}
// Redirección al servicio de Headless Prerender
const prerenderUrl = `${env.PRERENDER_SERVICE_URL}?url=${encodeURIComponent(url.toString())}`;
const prerenderResponse = await fetch(prerenderUrl, {
headers: {
'X-Prerender-Token': env.PRERENDER_TOKEN,
'User-Agent': userAgent || 'Prerender-Forwarder',
},
});
if (!prerenderResponse.ok) {
// Fallback al servidor original en caso de fallo del worker
return fetch(request);
}
const html = await prerenderResponse.text();
// Almacenamiento asíncrono del snapshot en Edge KV
ctx.waitUntil(
env.CACHE_KV.put(cacheKey, html, {
expirationTtl: 86400, // 24 horas
})
);
return new Response(html, {
status: prerenderResponse.status,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'X-Prerender-Cache': 'MISS',
'Vary': 'User-Agent',
'Cache-Control': 'public, max-age=3600, s-maxage=86400',
},
});
},
};Para sostener un TTFB < 300 ms, el navegador headless debe mantenerse precalentado, y las solicitudes no esenciales (imágenes pesadas, trackers analíticos, fuentes web) deben cancelarse mediante el Chrome DevTools Protocol.
import puppeteer, { Browser, Page } from 'puppeteer';
import { createClient, RedisClientType } from 'redis';
interface RenderOptions {
url: string;
timeoutMs?: number;
waitForSelector?: string;
}
export class PrerenderCluster {
private browser: Browser | null = null;
private redis: RedisClientType;
private readonly blockedResourceTypes = new Set<string>([
'image',
'media',
'font',
'websocket',
]);
private readonly blockedDomains: readonly string[] = [
'google-analytics.com',
'googletagmanager.com',
'mc.yandex.ru',
'facebook.net',
'hotjar.com',
];
constructor(redisUrl: string) {
this.redis = createClient({ url: redisUrl });
}
public async initialize(): Promise<void> {
await this.redis.connect();
this.browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--disable-gpu',
'--single-process',
'--no-zygote',
],
});
}
public async renderPage(options: RenderOptions): Promise<string> {
if (!this.browser) {
throw new Error('PrerenderCluster no está inicializado');
}
const { url, timeoutMs = 10000, waitForSelector = '#root > *' } = options;
const page: Page = await this.browser.newPage();
try {
// Optimización de red: intercepción y bloqueo de recursos secundarios
await page.setRequestInterception(true);
page.on('request', (req) => {
const resourceType = req.resourceType();
const reqUrl = req.url().toLowerCase();
const isDomainBlocked = this.blockedDomains.some((d) => reqUrl.includes(d));
if (this.blockedResourceTypes.has(resourceType) || isDomainBlocked) {
req.abort();
} else {
req.continue();
}
});
// Emulación del agente de escritorio Googlebot
await page.setUserAgent(
'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
);
// Carga de URL esperando finalización de la actividad de red
await page.goto(url, {
waitUntil: 'networkidle0',
timeout: timeoutMs,
});
// Espera de la bandera global o contenedor montado en React
await page.waitForFunction(
() => Boolean(window.__PRERENDER_READY__) || document.querySelector('#root > *') !== null,
{ timeout: timeoutMs }
);
// Extracción del documento HTML serializado
const html = await page.content();
return html;
} finally {
await page.close();
}
}
public async shutdown(): Promise<void> {
if (this.browser) {
await this.browser.close();
}
await this.redis.disconnect();
}
}
declare global {
interface Window {
__PRERENDER_READY__?: boolean;
}
}Para evitar que Chromium capture un snapshot en pleno renderizado de esqueletos de carga, la SPA debe emitir la señal global window.__PRERENDER_READY__ = true estrictamente tras resolver las peticiones asíncronas críticas e insertar las etiquetas meta.
import { useEffect } from 'react';
interface PrerenderOptions {
isDataLoaded: boolean;
hasErrors?: boolean;
}
export function usePrerenderSignal({ isDataLoaded, hasErrors = false }: PrerenderOptions): void {
useEffect(() => {
if (typeof window === 'undefined') return;
if (isDataLoaded || hasErrors) {
// Permitir a React culminar la fase de commit y pintado DOM
const rafId = requestAnimationFrame(() => {
window.__PRERENDER_READY__ = true;
});
return () => {
cancelAnimationFrame(rafId);
window.__PRERENDER_READY__ = false;
};
}
window.__PRERENDER_READY__ = false;
}, [isDataLoaded, hasErrors]);
}Si el reverse proxy no incluye Vary: User-Agent, las cachés intermedias de la CDN (Cloudflare, Fastly, Akamai) guardarán el snapshot estático generado para el rastreador y lo servirán a usuarios interactivos. En consecuencia, el usuario humano recibe un documento inerte con eventos rotos o sin hidratación.
Solución: Exigir en todas las capas del router la emisión de Vary: User-Agent en cada respuesta (tanto para bots como para usuarios humanos).
El navegador headless concluye su ciclo en el evento load o networkidle2 cuando la interfaz aún muestra un skeleton loader debido a peticiones fetch en vuelo. El buscador indexa texto genérico como "Cargando datos..." en lugar del contenido real.
Solución: Adoptar una validación de dos factores: comprobación del selector DOM junto con la verificación de window.__PRERENDER_READY__ mediante page.waitForFunction().
Ejecutar puppeteer.launch() ante cada petición entrante genera un proceso pesado en el SO. Con una carga sostenida de 50 peticiones/segundo, la memoria RAM se agota y los procesos colgados bloquean la CPU.
Solución: Operar un pool fijo de instancias precalentadas (generic-pool), aislar el renderizado en pestañas independientes (browser.newPage()) con cierre forzado en bloques finally y reciclar instancias cada 500 páginas procesadas.
Una SPA por defecto entrega index.html con código 200 para cualquier ruta. Si el navegador headless renderiza una pantalla 404 dentro de React, el bot recibe un código 200 OK con texto de "Página no encontrada", generando un Soft 404 que erosiona el crawl budget.
Solución: Insertar la etiqueta <meta name="prerender-status-code" content="404"> en la vista NotFound de React y programar el proxy para leerla y emitir el código HTTP 404 real.
El Reverse Proxy coteja el User-Agent con las firmas de rastreadores. Ante una coincidencia, activa el pre-renderizado.
Verifica si existe un snapshot válido por hash de URL. Ante un Cache Hit, responde en menos de 60 ms.
Ante un Cache Miss, asigna la URL a un worker precalentado del pool, bloqueando imágenes y trackers.
La página se carga, React ejecuta scripts, monta el DOM y declara window.__PRERENDER_READY__ = true.
Extrae el outerHTML del documento, lo guarda en Redis con el TTL configurado y lo envía al rastreador.