Проблема. У вас один 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). Правило: слот должен оборачивать только текстовый узел, без сиблингов.
Edge-функция
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). Когда нужно добавить новый оффер — правится только этот файл, перебилд автоматический.
Двойная защита: client-side как fallback
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 — это именно то, что нужно для платного трафика).
Автор: batyaro
