Подмена hero на edge по UTM: Cloudflare Pages Functions + HTMLRewriter для React SSG за 200 строк

от автора

Проблема. У вас один 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.tsximport { 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.tsimport { 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.tsexport 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-функция может не сработать в трёх случаях:

  1. Локальная разработка через pnpm dev — Vite не запускает CF Pages Functions.

  2. Cloudflare Workers перегружен (редко, но бывает).

  3. Тестовая ветка задеплоилась без 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 — это именно то, что нужно для платного трафика).

ссылка на оригинал статьи https://habr.com/ru/articles/1041964/