Реализация динамического контента на лендинге по UTM-меткам
Лендинг с динамическим контентом по UTM — это один URL, который показывает разный текст, изображения и офферы в зависимости от рекламной кампании, которая привела пользователя. Вместо создания десятков отдельных посадочных страниц для каждой кампании достаточно настроить замену контентных блоков на основе параметров URL.
Извлечение и хранение UTM-параметров
Параметры нужно читать при загрузке страницы и сохранять — они пропадают при переходах по внутренним ссылкам:
function extractUtmParams(): Record<string, string> {
const params = new URLSearchParams(location.search);
const utm: Record<string, string> = {};
['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'].forEach(key => {
const val = params.get(key);
if (val) utm[key] = val;
});
return utm;
}
function saveAndGetUtm(): Record<string, string> {
const fromUrl = extractUtmParams();
if (Object.keys(fromUrl).length > 0) {
sessionStorage.setItem('utm', JSON.stringify(fromUrl));
return fromUrl;
}
// Возвращаемся — параметров в URL нет, берём из сессии
return JSON.parse(sessionStorage.getItem('utm') ?? '{}');
}
const utm = saveAndGetUtm();
Карта замен контента
Создаётся JSON-конфиг (может лежать в файле, в CMS или в базе) — правила подстановки контента:
const contentMap = {
// По utm_campaign
campaigns: {
'google-search-brand': {
hero_title: 'Официальный сайт — лучший выбор для вашего бизнеса',
hero_subtitle: 'Прямые поставки. Без посредников.',
cta_text: 'Получить персональное предложение',
badge: 'Официальный партнёр',
},
'facebook-retargeting-cart': {
hero_title: 'Вы забыли кое-что важное',
hero_subtitle: 'Ваша корзина ждёт — завершите заказ сегодня.',
cta_text: 'Вернуться к корзине',
cta_url: '/cart',
},
'email-promo-march': {
hero_title: 'Спецпредложение для подписчиков',
hero_subtitle: 'Скидка 15% действует до конца месяца.',
cta_text: 'Активировать скидку',
badge: '-15%',
},
},
// По utm_source (фолбэк если нет совпадения по кампании)
sources: {
'yandex': {
hero_title: 'Нашли нас в Яндексе? Правильный выбор.',
cta_text: 'Узнать подробнее',
},
'vk': {
hero_title: 'Добро пожаловать из ВКонтакте',
cta_text: 'Смотреть каталог',
},
},
// Дефолт
default: {
hero_title: 'Решение для вашего бизнеса',
hero_subtitle: 'Надёжно, быстро, без лишних затрат.',
cta_text: 'Начать работу',
},
};
function getContent(utm: Record<string, string>) {
return contentMap.campaigns[utm.utm_campaign]
?? contentMap.sources[utm.utm_source]
?? contentMap.default;
}
Замена DOM-элементов
Атрибуты data-utm-key указывают, какое поле контента подставлять в элемент:
<h1 data-utm-key="hero_title">Решение для вашего бизнеса</h1>
<p data-utm-key="hero_subtitle">Надёжно, быстро, без лишних затрат.</p>
<a href="/start" data-utm-key="cta_text" data-utm-href="cta_url" id="main-cta">
Начать работу
</a>
<span class="badge" data-utm-key="badge" data-utm-hide-if-empty="true"></span>
function applyDynamicContent(content: Record<string, string>): void {
document.querySelectorAll('[data-utm-key]').forEach(el => {
const key = el.getAttribute('data-utm-key')!;
const value = content[key];
if (!value) {
if (el.getAttribute('data-utm-hide-if-empty') === 'true') {
(el as HTMLElement).style.display = 'none';
}
return;
}
el.textContent = value;
const hrefKey = el.getAttribute('data-utm-href');
if (hrefKey && content[hrefKey]) {
(el as HTMLAnchorElement).href = content[hrefKey];
}
});
}
// Запуск при загрузке
const content = getContent(utm);
applyDynamicContent(content);
Замена происходит до первого рендера если скрипт стоит в <head> с атрибутом defer, или сразу в конце <body>. При SSR лучше передавать UTM на сервер и рендерить нужный вариант сразу.
SSR-вариант на PHP/Laravel
// В Blade-шаблоне
@php
$utm = [
'utm_source' => request('utm_source') ?? session('utm_source'),
'utm_campaign' => request('utm_campaign') ?? session('utm_campaign'),
];
// Сохраняем в сессию при первом посещении
if (request('utm_source')) {
session(['utm_source' => request('utm_source')]);
session(['utm_campaign' => request('utm_campaign')]);
}
$content = App\Services\UtmContentService::resolve($utm);
@endphp
<h1>{{ $content['hero_title'] }}</h1>
<p>{{ $content['hero_subtitle'] }}</p>
<a href="{{ $content['cta_url'] ?? '/start' }}">{{ $content['cta_text'] }}</a>
SSR-подход не вызывает мерцания контента (flash of default content) — пользователь сразу получает персонализированную версию.
Трекинг эффективности вариантов
// Отправляем в GA4 какой вариант видит пользователь
gtag('event', 'utm_content_variant', {
utm_campaign: utm.utm_campaign ?? 'none',
utm_source: utm.utm_source ?? 'direct',
variant_key: utm.utm_campaign ?? utm.utm_source ?? 'default',
hero_title: content.hero_title?.substring(0, 50),
});
// На клик по CTA
document.getElementById('main-cta')?.addEventListener('click', () => {
gtag('event', 'utm_cta_click', {
cta_text: content.cta_text,
utm_campaign: utm.utm_campaign,
});
});
В GA4 строится сегментация по utm_content_variant — видно, какие кампании приводят к кликам по CTA, а какие заканчиваются отказом.
Сроки
JS-замена контента с картой кампаний до 20 вариантов: 4–6 часов. SSR-версия на PHP с сессионным хранением UTM: 3–4 часа. Трекинг вариантов в GA4 с сегментацией: 2 часа. Интеграция с CMS (редактор вариантов в админке): 1–2 дня.







