У класичних односторінкових додатках (Single Page Applications, SPA) на базі React браузер користувача спочатку отримує мінімалістичний HTML-документ: порожній контейнер <div id="root"></div> та посилання на JavaScript-бандл. Для користувачів рендеринг інтерфейсу відбувається на клієнті (Client-Side Rendering, CSR). Однак для пошукових систем та краулерів соціальних мереж ця архітектура створює фундаментальний бар'єр індексації.
Пошуковий робот Googlebot використовує спеціалізовану підсистему Web Rendering Service (WRS). Індексація CSR-додатків у Google відбувається у дві фази:
Крім того, пошукові боти Yandex, Baidu, Naver, а також парсери соціальних мереж (OpenGraph-краулери Telegram, Twitter/X, LinkedIn, Discord, WhatsApp) мають суттєво обмежені можливості виконання JS або взагалі не запускають скрипти. Якщо не надати готовий HTML у першій HTTP-відповіді, посилання залишаються без прев'ю, а сторінки випадають з індексу.
Архітектура Pre-rendering (динамічний рендеринг) розв'язує цю проблему без повного переписування кодової бази SPA на SSR-фреймворки на кшталт Next.js або Remix. Reverse Proxy перехоплює вхідні запити: звичайні користувачі отримують стандартний SPA-бандл, а запити від ідентифікованих пошукових ботів прозоро перенаправляються на пул headless-браузерів (Puppeteer, Playwright або Prerender.io), який генерує та кешує готові HTML-снапшоти сторінок.
В основі пререндерингу лежить поділ потоків обробки трафіку на рівні мережевого шлюзу (Reverse Proxy, CDN або Edge Worker). Система визначає тип клієнта за заголовком User-Agent та внутрішніми евристиками, після чого спрямовує запит відповідним контуром.
+-----------------------------------------------------------------------+
| Вхідний HTTP-запит |
+-----------------------------------------------------------------------+
|
v
+-----------------------------------------------------------------------+
| Reverse Proxy / CDN (Cloudflare / Nginx) |
| Аналіз User-Agent та прапора попереднього кешу |
+-----------------------------------------------------------------------+
| |
[Звичайний користувач] [Пошуковий бот / Краулер]
| |
v v
+---------------------------+ +---------------------------------+
| Статичний хостинг (S3) | | Кеш HTML-снапшотів (Redis/S3) |
| Віддача index.html + JS | +---------------------------------+
+---------------------------+ |
| +---------+---------+
v | |
+---------------------------+ [Cache Hit] [Cache Miss]
| Клієнтський браузер (CSR) | | |
| Гідратація та виконання JS| v v
+---------------------------+ +----------------+ +----------------+
| Віддача | | Headless Pool |
| готового HTML | | (Puppeteer) |
| (TTFB < 80ms) | | Рендер DOM |
+----------------+ +----------------+
|
+---------+
v
[Запис у Redis/S3]
|
v
[Віддача HTML боту]Ключові сутності архітектури:
Маршрутизатор аналізує заголовок User-Agent. Список сигнатур містить ботів Google, Yandex, Bing, соцмереж та AI-пошуковиків (Perplexity, GPTBot). Критично важливо передавати заголовок Vary: User-Agent, щоб CDN не віддавала закешований бот-снапшот живому користувачеві.
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');
// Пропуск статичних асетів повз пререндеринг
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,
});
}
// Перевірка наявності закешованого снапшота в 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',
},
});
}
// Маршрутизація на 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 на оригінальний SPA-сервер у разі збою воркера
return fetch(request);
}
const html = await prerenderResponse.text();
// Асинхронне збереження снапшота в Edge KV
ctx.waitUntil(
env.CACHE_KV.put(cacheKey, html, {
expirationTtl: 86400, // 24 години
})
);
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',
},
});
},
};Для підтримки TTFB < 300 мс headless-браузер повинен бути попередньо запущений, а другорядні мережеві запити (завантаження важких зображень, зовнішніх аналітичних трекерів, шрифтів) повинні блокуватися на рівні 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 не ініціалізовано');
}
const { url, timeoutMs = 10000, waitForSelector = '#root > *' } = options;
const page: Page = await this.browser.newPage();
try {
// Оптимізація мережі: блокування несуттєвих ресурсів
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();
}
});
// Емуляція десктопного Googlebot
await page.setUserAgent(
'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
);
// Перехід на сторінку з очікуванням завершення мережевої активності
await page.goto(url, {
waitUntil: 'networkidle0',
timeout: timeoutMs,
});
// Очікування готовності кастомного прапора або селектора в React
await page.waitForFunction(
() => Boolean(window.__PRERENDER_READY__) || document.querySelector('#root > *') !== null,
{ timeout: timeoutMs }
);
// Витягнення повного HTML-документа
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;
}
}Щоб Chromium не робив снапшот у момент показу скелетона завантаження, React-додаток повинен виставити глобальний прапор window.__PRERENDER_READY__ = true суворо після завершення всіх критичних асинхронних запитів та оновлення мета-тегів.
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) {
// Даємо React завершити фазу commit та оновлення DOM
const rafId = requestAnimationFrame(() => {
window.__PRERENDER_READY__ = true;
});
return () => {
cancelAnimationFrame(rafId);
window.__PRERENDER_READY__ = false;
};
}
window.__PRERENDER_READY__ = false;
}, [isDataLoaded, hasErrors]);
}Якщо зворотний проксі не додає заголовок Vary: User-Agent, проміжний кеш CDN (Cloudflare, Fastly, Akamai) закешує статичний HTML-снапшот, згенерований для пошукового бота, і поверне його звичайному користувачеві. У результаті живий користувач отримає сторінку без інтерактивності або зі зламаними клієнтськими скриптами.
Рішення: На всіх рівнях маршрутизації гарантуйте передачу Vary: User-Agent в усіх відповідях (як для ботів, так і для звичайних клієнтів).
Headless-браузер завершує роботу за подією load або networkidle2, коли на сторінці ще відображається лоадер або порожній скелетон, оскільки фоновий запит до API через fetch ще не повернув дані. Пошуковий бот індексує порожню сторінку або текст «Завантаження даних...».
Рішення: Використовуйте двофакторну готовність: очікування селектора контенту плюс перевірка прапора window.__PRERENDER_READY__ через page.waitForFunction().
Кожен виклик puppeteer.launch() на вхідний запит призводить до запуску окремого процесу ОС. Під навантаженням у 50 запитів на секунду сервер вичерпує RAM, а процеси зависають, паралізуючи сервер.
Рішення: Експлуатуйте фіксований пул попередньо створених інстансів через бібліотеки управління пулами (generic-pool), ізолюйте рендеринг в окремих вкладках browser.newPage() з обов'язковим закриттям у блоці finally і перезапускайте інстанс браузера після кожних 500 відрендерених сторінок.
SPA за замовчуванням віддає index.html зі статусом 200 для будь-яких шляхів. Якщо headless-браузер рендерить сторінку 404 всередині React, пошуковий бот отримує статус 200 OK з текстом «Сторінку не знайдено» — виникає критична помилка Soft 404, що руйнує пошукову видимість сайту.
Рішення: Впроваджуйте метатег <meta name="prerender-status-code" content="404"> всередині React-компонента NotFound та інструктуйте проксі зчитувати цей тег для виставлення коректного HTTP-коду відповіді.
Reverse Proxy звіряє User-Agent з базою краулерів. У разі збігу ініціюється контур пререндерингу.
Перевіряється наявність актуального HTML-снапшота за хешем URL. При Cache Hit відповідь віддається миттєво з TTFB < 60 мс.
При Cache Miss запит встає в чергу до пулу Headless Chromium. Блокуються медіаресурси та трекери.
Сторінка завантажується в браузері, виконуються скрипти, React монтує DOM і виставляє window.__PRERENDER_READY__ = true.
HTML витягується з document.documentElement.outerHTML, відправляється в Redis із заданим TTL і повертається боту.