Подмена hero на edge по UTM: Cloudflare Pages Functions + HTMLRewriter для React SSG за 200 строк
- вторник, 2 июня 2026 г. в 00:00:19
Проблема. У вас один SSG-лендинг, на который льётся платный трафик из 12 разных рекламных кампаний. Каждая группа объявлений сделана под свою боль ЦА: «AI-сотрудники», «AI-агенты», «стратегическая сессия», «управленческая отчётность». Все ведут на один дефолтный hero «ИИ для бизнеса». Конверсия в заявку проседает на 30–50% по сравнению с разнотемными лендингами под каждую группу. Делать 12 отдельных лендингов — дорого по разработке и убивает SEO. Подменять hero JavaScript-ом на клиенте — FOUC, плохой Core Web Vitals, и Яндекс/Google видят дефолт.
В этой статье — рабочая схема, которую мы поставили в продакшен за один день: edge-функция Cloudflare Pages переписывает HTML на лету через HTMLRewriter, SSG остаётся первым источником истины, client-side React выполняет ту же логику при гидратации. 200 строк кода, ноль зависимостей сверх стандартных, латенси без изменений (HTMLRewriter работает потоком), Lighthouse не страдает.
Делать N статичных лендингов — растёт с числом UTM-вариантов линейно, ломает каноникализацию, дублирует SEO-сигналы. Для 12 кампаний — 12 копий контента, которые надо синхронизировать каждый раз когда меняется блок ниже hero.
Client-side подмена через React.useEffect — FOUC: пользователь видит дефолтный hero, потом он мгновенно меняется. На медленном соединении видна вспышка, на быстром — заметна, потому что hero — это первое что глаз фокусирует. Дополнительно — Яндекс и Google видят при первом рендере дефолт, что для SEO не критично, но для рекламных платформ (Quality Score) — критично.
Server-side рендер с переменными в URL — требует Next.js / Remix с SSR, runtime-стоимость, более сложный деплой. Если у вас уже SSG (vite-react-ssg, Astro, Eleventy) — это шаг назад.
Edge Workers с HTMLRewriter — переписывание HTML на потоке между origin и клиентом. Latency-overhead единицы миллисекунд. SSG как был, так и остался — функция работает поверх. Это и есть то, что мы выбрали.
┌─────────────────┐ GET /?utm_offer=ai-agents ┌──────────────────────┐ │ Browser │ ────────────────────────────▶ │ Cloudflare Edge │ └─────────────────┘ │ │ ▲ │ ┌────────────────┐ │ │ │ │ _middleware.ts │ │ │ │ │ читает UTM, │ │ │ │ │ next() в SSG, │ │ │ │ │ HTMLRewriter │ │ │ │ │ переписывает │ │ │ │ └────────┬───────┘ │ │ └───────────┼──────────┘ │ │ │ ▼ │ ┌──────────────────────┐ │ │ Static SSG asset │ │ │ /index.html │ │ подменённый HTML │ с data-offer-slot=* │ └──────────────────────────────────────────└──────────────────────┘
Ключевая идея — data-attribute якоря. В React-компоненте Hero ставим атрибуты на DOM-узлы которые могут подменяться:
// src/sections/Hero/Hero.tsx import { useSearchParams } from 'react-router-dom' import { resolveOffer } from '@/data/offers' export function Hero() { const [searchParams] = useSearchParams() const offer = resolveOffer(searchParams.get('utm_offer')) return ( <section> <div data-offer-slot="eyebrow-wrap"> <span aria-hidden className="dot" /> <span data-offer-slot="eyebrow">{offer.eyebrow}</span> </div> <h1> <span data-offer-slot="h1">{offer.h1}</span> <br /> <span data-offer-slot="h1-sub">{offer.h1Sub}</span> </h1> <p data-offer-slot="lede">{offer.lede}</p> <a href="#cta" className="btn-primary"> <span data-offer-slot="cta">{offer.ctaText}</span> </a> </section> ) }
data-offer-slot — единственная вещь, которая знают оба слоя: и React, и edge-функция. Это контракт между ними.
Чуть подробнее про слоты: я специально поставил data-offer-slot="eyebrow" на внутренний span, а не на родительский div. Если поставить на родителя, HTMLRewriter затрёт decorative-элементы (точка <span aria-hidden> слева от eyebrow). Правило: слот должен оборачивать только текстовый узел, без сиблингов.
Cloudflare Pages поддерживает функции в каталоге functions/ рядом с кодом. Файл _middleware.ts отрабатывает для всех путей в проекте (если не возвращён context.next() явно).
// functions/_middleware.ts import { OFFERS, isOfferKey } from '../src/data/offers' export const onRequest: PagesFunction = async (context) => { const url = new URL(context.request.url) // Подмена работает только на корне. Для /blog, /guides и т.п. // у каждой страницы свой смысл, hero подменять не нужно. if (url.pathname !== '/' && url.pathname !== '/index.html') { return context.next() } if (context.request.method !== 'GET') { return context.next() } const utmOffer = url.searchParams.get('utm_offer') if (!isOfferKey(utmOffer)) { // Дефолтный hero уже в SSG — отдаём как есть. return context.next() } const offer = OFFERS[utmOffer] const response = await context.next() // Гарантируем что это HTML, прежде чем парсить через HTMLRewriter. const contentType = response.headers.get('Content-Type') ?? '' if (!contentType.includes('text/html')) { return response } const rewriter = new HTMLRewriter() .on('[data-offer-slot="eyebrow"]', textReplacer(offer.eyebrow)) .on('[data-offer-slot="h1"]', textReplacer(offer.h1)) .on('[data-offer-slot="h1-sub"]', textReplacer(offer.h1Sub)) .on('[data-offer-slot="lede"]', textReplacer(offer.lede)) .on('[data-offer-slot="cta"]', textReplacer(offer.ctaText)) const rewritten = rewriter.transform(response) // Cache-Control: private — варианты hero не должны кешироваться // на CDN-уровне как общий ресурс. const newHeaders = new Headers(rewritten.headers) newHeaders.set('Cache-Control', 'private, no-store') newHeaders.set('Vary', 'Accept-Encoding') newHeaders.set('X-Offer-Variant', utmOffer) return new Response(rewritten.body, { status: rewritten.status, statusText: rewritten.statusText, headers: newHeaders, }) } function textReplacer(text: string) { return { element(el: Element) { // html: false — escape'ит спецсимволы, безопасно для XSS. el.setInnerContent(text, { html: false }) }, } }
Что важно в этом коде:
return context.next() пять раз в начале — это early return для всех случаев, когда подмена не нужна. Главное правило edge-функций: не работать там, где не надо. Любая лишняя обработка добавляется к TTFB.
HTMLRewriter — потоковый парсер. Он не загружает весь HTML в память, а проходит токен за токеном. Для большого SSG-HTML (у нас ~70 КБ) это означает что first-byte отдаётся почти сразу после первого совпадения селектора, а не после полного парсинга. Замер на нашем сайте: +3–5 мс к TTFB на edge, незаметно.
setInnerContent(text, { html: false }) — безопасный режим. HTMLRewriter автоматически escape’ит <, >, &, " если передан { html: false }. Это критично — данные приходят из URL-параметра, доверять им нельзя. Если кто-то откроет ?utm_offer=<script>...</script>, мой isOfferKey отфильтрует это раньше, но defence-in-depth никогда не лишний.
Cache-Control: private, no-store — для подменённых вариантов. Без этого CF-edge закеширует первый вариант с utm_offer=ai-agents и начнёт отдавать его всем посетителям того же edge-узла. SSG-дефолт остаётся public, cacheable.
Импорт from '../src/data/offers' — Cloudflare Pages при сборке функций умеет бандлить TypeScript-зависимости из соседних каталогов. Это позволяет иметь один источник истины для офферов: и React, и edge читают из одного файла. Альтернатива — дублировать данные в functions/_lib/, что мы пробовали и отбросили из-за рассинхрона.
// src/data/offers.ts export type OfferKey = | 'ai-employees' | 'ai-agents' | 'strat-session' | 'analytics' | 'automation' | 'ai-crm' export type Offer = { eyebrow: string h1: string h1Sub: string lede: string ctaText: string } export const DEFAULT_OFFER: Offer = { eyebrow: 'Для собственников · выручка от 50 млн ₽', h1: 'ИИ-сотрудники для роста маржи и масштабирования.', h1Sub: 'AI-архитектура с KPI на P&L. Инжиниринг, не автоматизация.', lede: '…', ctaText: 'AI-диагностика (30 мин, бесплатно)', } export const OFFERS: Record<OfferKey, Offer> = { 'ai-agents': { eyebrow: 'Для собственников · выручка от 50 млн ₽', h1: 'Разработаем AI-агентов под задачи вашего бизнеса.', h1Sub: 'От бота-обработчика до автономного аналитика. С KPI на P&L.', lede: '…', ctaText: 'AI-диагностика (30 мин, бесплатно)', }, // … остальные 5 офферов } export function isOfferKey(value: unknown): value is OfferKey { return typeof value === 'string' && value in OFFERS } export function resolveOffer(utmOffer: string | null | undefined): Offer { if (utmOffer && isOfferKey(utmOffer)) { return OFFERS[utmOffer] } return DEFAULT_OFFER }
Один файл, типизированный мап, два разрешителя (тип-guard isOfferKey для edge и сам resolver resolveOffer для React). Когда нужно добавить новый оффер — правится только этот файл, перебилд автоматический.
Edge-функция может не сработать в трёх случаях:
Локальная разработка через pnpm dev — Vite не запускает CF Pages Functions.
Cloudflare Workers перегружен (редко, но бывает).
Тестовая ветка задеплоилась без functions/.
Чтобы вариант оффера всё равно загрузился, React-компонент тоже читает utm_offer через useSearchParams и подставляет правильный hero на клиенте. Это idempotent — если edge уже переписал HTML, React видит ту же строку в DOM, виртуальный DOM не отличается, патч не применяется. Если не переписал — React делает работу при гидратации.
Это особенно важно для preview-deploy’ев и для случаев когда пользователь шарит URL https://site.com/?utm_offer=ai-agents друзьям без proxy — friendly URL открывается с правильным контентом везде.
HTMLRewriter не работает с мульти-нодными слотами. Если у вас в DOM <div data-offer-slot="x"><span>часть1</span> <em>часть2</em></div> — setInnerContent затрёт и span, и em. Решение: ставить слот на лист дерева (текстовый узел), декоративные сиблинги выносить за пределы слота.
Cache-Control: private важен. Без него CF-edge закеширует первый вариант. Я этот шаг забыл в первой версии — два дня все юзеры с edge-узла Frankfurt видели ?utm_offer=strat-session независимо от UTM. Тестируйте подмену с разных IP и разных географий.
vite-react-ssg использует data-router (react-router-dom v6.4+). Это интересный side-effect: data-router глобально перехватывает клики на <a href="/..."> внутри <RouterProvider>. Если у вас в public/ лежит статичная страница вне SPA-routes — клик на ссылку к ней приводит к 404 от React, а не к навигации браузера. Лечится onClick={(e) => { e.preventDefault(); window.location.assign(href + '/') }} для статичных путей.
Bundle cache на JS-файлы. Cloudflare ставит Cache-Control: public, max-age=31536000, immutable на /assets/*.js. Это правильно (hash в имени файла гарантирует cache-bust при изменении), но если вы тестируете через DevTools с включённым кэшем — старый JS-бандл может не подхватить новую версию сразу после деплоя. Hard refresh обязателен.
Edge-цена. Cloudflare Pages даёт 100k бесплатных function-invocations/день. Подмена hero — это 1 invocation на каждый запрос корня сайта. На нашем трафике (несколько тысяч в день) — далеко от лимита. Если у вас миллионы PV — может быть смысл смотреть на Cloudflare Workers Paid tier ($5/мес за 10M invocations).
Если контент сильно структурный. HTMLRewriter работает на уровне текста внутри элементов. Если вам нужно подменить целые блоки (другая структура секций, другие компоненты) — это не HTMLRewriter, это R/SSR.
Если SEO критично для каждой UTM-вариации. Подменённый контент HMRR-фильтруется через Cache-Control: private — Яндекс/Google его не видят. Они видят только дефолтный SSG. Если вам нужно ранжироваться по UTM-вариациям (что нелогично, но бывает) — нужно делать настоящие отдельные URL’ы.
Если у вас не Cloudflare. Vercel Edge Functions поддерживают похожий API (new HTMLRewriter()), но API чуть отличается. Netlify Edge Functions работают через Deno и HTMLRewriter доступен через polyfill. AWS CloudFront Functions — нет нативного HTMLRewriter, надо писать своё. Самый чистый стек — именно Cloudflare Pages.
Сейчас у нас 6 вариантов hero под 12 групп объявлений (некоторые группы делят оффер). Чтобы убедиться что схема даёт прирост конверсии — нужно: 1) измерить базовую конверсию на дефолте, 2) измерить на каждой UTM-вариации, 3) сравнить с A/B-контролем где половина трафика по той же UTM получает дефолтный hero. Этот эксперимент в планах, отпишу результаты отдельным постом через 4–6 недель когда соберём статистическую значимость.
Также — голосовые версии офферов через текст-в-голос на edge для тех, кто ходит на сайт с iPhone в наушниках. Это уже next-next-step.
Если кто-то делает похожую edge-подмену — интересно почитать в комментариях про другие подходы. Особенно — про работу с динамическими блоками (карточки, картинки), потому что у HTMLRewriter тут много нюансов.
Код в одном репозитории, без секретов, можно адаптировать под свой проект: я не выкладываю весь репо целиком (там много кастомного), но кусок про подмену — это ровно те 200 строк что приведены выше. Лицензия MIT, можете брать и форкать.
Если кто-то делает похожую edge-подмену — интересно почитать в комментариях про другие подходы. Особенно про работу с динамическими блоками (карточки, картинки) — у HTMLRewriter тут много нюансов.
Код целиком в публичном репозитории — минимальный самодостаточный пример: middleware с HTMLRewriter, мапа офферов, React-компонент с data-offer-slot, статичный HTML без React, README с граблями из прода. Лицензия MIT, берите и форкайте.
Production-проверено на dnai.engineering — открой ?utm_offer=ai-agents, ?utm_offer=strat-session, ?utm_offer=ai-employees и увидишь подмену вживую (или curl покажет три разных h1 по одному и тому же URL — это именно то, что нужно для платного трафика).