Lighthouse 100 / 100: как мы повесили GTM, GA4, Яндекс.Метрику и Clarity на статический сайт — и не уронили скорость

от автора


Когда маркетологи хотят всё: сырые данные в GA4, запись сессий в Метрике, хитмапы в Clarity и при этом «Lighthouse 100» в PageSpeed Insights — приходится изобретать. Расскажу, как мы это сделали на небольшом проекте и во что это обошлось по времени и нервам.

Проект — нишевый агрегатор российских хостинг-провайдеров. Более 120 страниц в sitemap, 31 статья, десятки категорий услуг, живые цены, сравнения. Стек: Astro 6 + Strapi 5 + Tailwind 4, плюс Partytown, PostgreSQL, Nginx и обычный VPS на Ubuntu. Сайт собирается в статику во время билда, никакого SSR в рантайме нет.

На desktop — Lighthouse 100 / 100 / 100 / 100. На mobile с жёстким throttling (4x slow CPU) — 99 / 100 / 100 / 100. В реальных условиях и по Chrome UX Report — 100 везде. LCP на desktop — 0,5 секунды, на mobile throttled — 1,7 секунды. CLS — ноль. TBT — 10 ms на мобильном и 0 ms на десктопе.

Lighthouse на десктопе: 100 / 100 / 100 / 100. LCP 0,5 с, TBT 0 ms, CLS 0. Обычный прогон без throttling.

Lighthouse на десктопе: 100 / 100 / 100 / 100. LCP 0,5 с, TBT 0 ms, CLS 0. Обычный прогон без throttling.
Тот же сайт на mobile с жёстким throttling (4x slow CPU): 99 / 100 / 100 / 100. LCP 1,7 с, TBT 10 ms, CLS 0. У реальных пользователей в Chrome UX Report — 100 везде.

Тот же сайт на mobile с жёстким throttling (4x slow CPU): 99 / 100 / 100 / 100. LCP 1,7 с, TBT 10 ms, CLS 0. У реальных пользователей в Chrome UX Report — 100 везде.

Дальше — по порядку. Почему Astro, а не Next.js и не Nuxt. Как Strapi живёт рядом со статикой и почему нас чуть не похоронила одна его особенность. Две недели поиска причин, почему ломалась тёмная тема в Tailwind 4. И, главное, как Partytown вырвал 1 190 миллисекунд блокировки из главного потока и переписал нам результат PageSpeed Insights.


Почему Astro, а не Next.js или Nuxt

Сначала рассматривали Next.js 14 с App Router. Но для контентного сайта с 120+ страницами, где 90% контента — тексты, таблицы и карточки, SSR и гидратация React-компонентов были избыточны.

Astro дал три ключевых преимущества:

  • Zero-JS по умолчанию. Страница генерируется в чистый HTML на этапе сборки. JavaScript грузится только для интерактивных элементов — калькулятора, квиза — через Islands Architecture. HTML-страница весит 15-25 KB, а не 200+ KB с React-рантаймом.

  • Partial hydration. Компоненты с client:load или client:idle гидратируются изолированно. Остальная страница остаётся статической. Это критично для метрик — нет блокирующего парсинга скриптов в <head>.

  • Контент-фокус. Astro из коробки умеет работать с Markdown, MDX и любой CMS через loaders. Не пришлось городить костыли для динамических маршрутов вроде /catalog/[slug].astro.

// astro.config.mjs — минимальная конфигурацияimport { defineConfig } from 'astro/config';import tailwindcss from '@tailwindcss/vite';export default defineConfig({  output: 'static',  prefetch: true,  vite: {    plugins: [tailwindcss()],  },});

Как Strapi v5 работает как Headless CMS для SSG

Стандартный паттерн: CMS на Node.js отдаёт JSON по REST, фронтенд запрашивает данные на каждый вход. У нас всё наоборот. Фронтенд ходит в Strapi только во время сборки. Всё остальное время Strapi живёт своей жизнью на PM2, отдаёт редакторам свой admin-UI, и сайту до него нет никакого дела.

// src/lib/strapi.ts — fetch на этапе buildexport async function fetchProviders() {  const res = await fetch(`${STRAPI_URL}/api/providers?populate=*`, {    headers: { Authorization: `Bearer ${TOKEN}` },  });  const json = await res.json();  return json.data || [];}

В каждом .astro-файле данные запрашиваются в frontmatter-секции (server-side во время сборки). Результат — статический HTML с уже встроенными данными. Пользователю не нужно ждать API-запросов.

// src/pages/providers/[slug].astroexport async function getStaticPaths() {  const providers = await fetchProviders();  return providers.map((p) => ({    params: { slug: p.slug },    props: { data: p },  }));}const { data } = Astro.props;

Strapi v5 с Document Service API и documentId вместо числовых id потребовал адаптации — мы написали обёртку, которая нормализует ответы (поддерживает и старый attributes-формат, и новый плоский). Главный плюс: Strapi управляет контентом, сложная бизнес-логика (affiliate-ссылки, pluralization, UTM) живёт во фронтенде.

Главные грабли, на которые наступили. После npm run build папка dist/ содержит только скомпилированный admin-интерфейс. Конфиг и schema она не копирует. Если забыть ручные cp -r config dist/config && cp -r src dist/src и рестартнуть PM2 — при следующем старте получишь пустой проект. Чуть не потеряли весь контент в тестовом окружении. С тех пор у нас железное правило: перед любым ребилдом Strapi — dump БД, после сборки — копирование config и src в dist.


Tailwind CSS 4: почему миграция съела времени впятеро больше ожидаемого

Проект стартовал на Tailwind 3.4. Когда вышел v4 с CSS-first конфигурацией, мы решили мигрировать — хотели избавиться от tailwind.config.js и перейти на нативные CSS-директивы.

/* src/styles/global.css — конфигурация Tailwind 4 */@import "tailwindcss";@theme {  --color-brand: #2563eb;  --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;}@custom-variant dark (&:is(.dark *));

Что сломалось:

  1. Scoped styles с @apply. В Astro <style> блоках @reference "tailwindcss" не наследовал @custom-variant dark. Компилировалось в @media(prefers-color-scheme:dark) вместо .dark. Полтора дня пытались понять, почему в layout компоненте class:list={['dark:bg-slate-900']} работает по клику кнопки темы, а в <style> рядом — нет. Пофиксили через @reference "../../styles/global.css" с правильным относительным путём — в доках этого нет, нашли по старым GitHub issues.

  2. Переменные карточек. Мы использовали CSS-переменные для темизации компонентов (--card-bg, --text-primary). Tailwind 4 с Vite-plugin иногда tree-shake’ил неиспользуемые переменные из :root — страница собиралась, в браузере выглядела как белый прямоугольник без фоновых цветов. Пришлось явно прописать все кастомные переменные в global.css и отдельно в селекторе .dark, иначе PostCSS-проход их выбрасывал.

  3. Классы вида dark:bg-[#1e293b]. Работают, но #1e293b — это не slate-900. Мы осознанно используем #1e293b для синего оттенка тёмной темы, и Tailwind 4 это не сломал, но важно следить за arbitrary values.

  4. Логотипы в тёмной теме. Провайдеры дают логотипы в двух вариантах — для светлого и тёмного фона. Сделали переключение через <img class="dark:hidden" /> + <img class="hidden dark:block" /> — это работает без JS и не вызывает FOUC. Два HTTP-запроса на логотип не идут — Astro инлайнит SVG лого в HTML.

Итог: миграция заняла 2 дня по факту, хотя рассчитывали на 4 часа. Зато убрали лишний JS-конфиг и получили нативную скорость Vite — сборка всего каталога проходит за полминуты.


Schema.org и AI-SEO: зачем тратить время

Каждая страница сайта генерирует JSON-LD структурированные данные:

  • Organization + WebSite — на всех страницах

  • BreadcrumbList — навигационная цепочка

  • ItemList — списки провайдеров и категорий

  • FAQPage — блоки вопрос-ответ (AnswerUnit)

  • BlogPosting — для статей блога

<script type="application/ld+json">{  "@context": "https://schema.org",  "@type": "FAQPage",  "mainEntity": [{    "@type": "Question",    "name": "Какой VPS выбрать для WordPress?",    "acceptedAnswer": {      "@type": "Answer",      "text": "Для WordPress подойдёт VPS с 2+ GB RAM..."    }  }]}</script>

Плюс мы добавили llms.txt и llms-full.txt — краткое и полное описание сайта для AI-краулеров (GPTBot, PerplexityBot, ClaudeBot). В robots.txt разрешены все основные боты. Это не классический SEO, а GEO (Generative Engine Optimization) — оптимизация под ответы AI-ассистентов.


Partytown: как мы вынесли 1 190 ms TBT в Web Worker

Это главная история проекта. На старте всё было прозаично: GTM, GA4 (через GTM-контейнер), Яндекс.Метрика с Вебвизором, Microsoft Clarity. Все скрипты грузились лениво — после первого user-interaction. Считали, что этого достаточно.

Lighthouse на mobile в режиме throttled показал другое. Performance score — 76. TBT — 1 190 ms. Reduce JS execution time — 1,5 секунды. Reduce unused JavaScript — 163 KiB. Картина грустная.

Откуда такие цифры? Lighthouse симулирует не только медленную сеть, но и slow CPU 4x — и рандомно триггерит user events вроде mousemove. Наш «ленивый» загрузчик немедленно реагировал и грузил GTM прямо во время аудита. Итог: 1 516 ms script evaluation на main thread из-за GTM, Метрики и Clarity вместе. При этом CrUX (реальные данные пользователей Chrome) показывал 100. В продакшене всё было хорошо. А лаб-аудит для PageSpeed Insights врал безбожно.

Полумеры мы перепробовали:

  • Убрали mousemove из триггеров — Lighthouse его симулирует автоматически. Помогло на 5-10 пунктов.

  • Увеличили idle timeout с 3 до 10 секунд — Lighthouse-аудит длится 6-9 с, GTM не успевал. Снова +5 пунктов, но variance ±10 пунктов между запусками.

  • Обсуждали server-side GTM через свой домен (sgtm.example.com) — но это отдельный Docker-контейнер, лишняя инфраструктура и лишние расходы.

Финальное решение — @astrojs/partytown. Это Astro-обёртка над Partytown от Builder.io, которая переносит сторонние скрипты в Web Worker. Main thread остаётся чистым, аналитика работает в фоне.

Подключение в astro.config.mjs:

import partytown from '@astrojs/partytown';export default defineConfig({  integrations: [    partytown({      config: {        forward: [          'dataLayer.push',  // GTM          'gtag',            // GA4          'ym',              // Яндекс.Метрика        ],        debug: false,      },    }),  ],});

forward — список глобальных функций, которые проксируются с main thread в worker. Каждый вызов gtag('event', ...) сериализуется и отправляется в worker через MessageChannel.

Дальше делаем GTM-скрипт partytown-совместимым. В компоненте GoogleTagManager.astro:

<!-- Inline на main thread: dataLayer + Consent Mode v2 default = denied --><script is:inline>  window.dataLayer = window.dataLayer || [];  window.gtag = function () { window.dataLayer.push(arguments); };  window.gtag('consent', 'default', { ad_storage: 'denied', /* ... */ });  window.dataLayer.push({ 'gtm.start': Date.now(), event: 'gtm.js' });</script><!-- Lazy loader: GTM грузится только по user interaction или 8 с idle.     Partytown подхватывает type="text/partytown" и переносит в worker. --><script is:inline>  function loadGTM() {    if (window.__gtmLoaded) return;    window.__gtmLoaded = true;    var s = document.createElement('script');    s.type = 'text/partytown';    s.src = 'https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX';    document.head.appendChild(s);  }  ['scroll', 'click', 'keydown', 'touchstart'].forEach(ev =>    window.addEventListener(ev, loadGTM, { once: true, passive: true })  );  if ('requestIdleCallback' in window) {    requestIdleCallback(loadGTM, { timeout: 8000 });  } else {    setTimeout(loadGTM, 8000);  }</script>

Аналогично для Яндекс.Метрики и Clarity — внутри их init-функции добавили script.type = 'text/partytown' перед script.src.

Что чуть не сломалось

Content Security Policy. Partytown создаёт Web Worker через Blob URL и выполняет скрипты через fetch() и eval(). Это требует трёх правок в CSP:

add_header Content-Security-Policy "  default-src 'self';  script-src 'self' 'unsafe-inline' 'unsafe-eval' ... ;  worker-src 'self' blob:;  child-src 'self' blob:;  connect-src 'self' https://www.googletagmanager.com https://cdn.jsdelivr.net ... ;" always;

worker-src 'self' blob: — без этого worker не стартует. cdn.jsdelivr.net — потому что наш GTM-контейнер использует сторонний GTM-шаблон для Яндекс.Метрики, который динамически подгружает вспомогательный JS с jsdelivr. Без него в консоли висели CSP-violations и Best Practices падал до 96. Дебажили это через DevTools console — ошибки приходили из blob://, не из основного контекста, что путало.

Microsoft Clarity и forward. Сначала добавили 'clarity' в forward — получили TypeError: Cannot read properties of undefined (reading 'apply') в worker. Clarity внутри использует window.parent и свою очередь, которая конфликтует с partytown-проксями. Убрали clarity из forward — Clarity отлично работает в worker через свой <script type="text/partytown">, просто без main-thread прокси для вызовов извне (нам и не нужны).

Что получили

Слайд «до и после» по mobile throttled. Performance: было 76, стало 99. TBT: было 1 190 ms, стало 10 ms. Script evaluation на main thread: было 1 516 ms, стало около 50 ms. Best Practices и реальные пользователи в Chrome UX Report и были 100, и остались.

163 KiB GTM-кода больше не висят в main thread bundle. Они выполняются в worker и не блокируют рендер. При этом всё работает как раньше: партнёрская аналитика, Вебвизор с записью сессий, хитмапы Clarity. Просто на отдельном потоке.

Стабильные 100/100 на mobile throttled у нас не получились — Lighthouse даёт variance ±5 пунктов, потому что иногда симулирует interaction в первые две секунды (тогда GTM грузится в worker и съедает 100–150 ms TBT на setup). Реальные пользователи и CrUX этого не видят. Решили остановиться на 99: выжимать ещё пункт через server-side GTM нерентабельно.


Core Web Vitals: итоговые цифры

Сводно по Lighthouse: на десктопе 100 / 100 / 100 / 100, на мобильном с throttling — 99 / 100 / 100 / 100. Разбивка по Core Web Vitals на mobile throttled: LCP 1,7 секунды, FCP 1,4, CLS 0, TBT 10 ms, Speed Index 1,4. Без throttling, как в лаб-прогоне desktop: LCP 0,5 секунды, TBT 0 ms, CLS 0. TTFB по RUM — около 80 ms для российского региона и 50 ms для Европы. CDN не используем.

Что это дало в сумме:

  • Статическая генерация плюс Nginx с gzip и brotli.

  • Сторонний JS в Web Worker через Partytown.

  • inlineStylesheets: 'always' — весь CSS в HTML, ни одного render-blocking запроса.

  • Картинки через <Image /> Astro с Sharp в WebP и AVIF.

  • prefetch для внутренних ссылок (стратегия viewport).

  • Минимальный JS на основном потоке: квиз, мобильное меню и переключатель темы.

  • Полный набор security headers: HSTS, CSP с worker-src, X-Frame-Options, COOP, COEP, Referrer-Policy.


Что пошло не так: честный блок

  1. Strapi v5 Document Service. Миграция с v4 потребовала переписать все запросы. strapi.documents() не поддерживает relations (connect/set), поэтому для связей пришлось использовать REST API. Для плагинов — отдельная история: ни один плагин не имел стабильной версии под v5, пришлось форкать и собирать из main-веток.

  2. Draft & Publish дублирование. Strapi v5 хранит draft и published как отдельные записи в PostgreSQL. При обновлении контента через API нужно синхронизировать обе версии, иначе данные расходятся. Столкнулись, когда изменённая цена провайдера появлялась в admin-UI, но API отдавал старую draft. Решили через фильтр ?status=published в сборочных фетчах.

  3. Пустые каталожные страницы. При очередном пересмотре каталога часть категорий услуг оставалась без карточек — страницы показывали пустой листинг. Для Google это thin content. Добавили <meta name="robots" content="noindex"> для пустых каталогов, исключили их из sitemap.xml и из внутренних линков.

  4. Мобильное меню. Header с position: fixed и большим количеством пунктов в мобильном меню уходил за viewport без скролла на iPhone SE. Потребовалось max-height: calc(100dvh - 6rem) + overflow-y: auto + блокировка скролла страницы при открытом меню (document.body.style.overflow = 'hidden'). 100dvh вместо 100vh — потому что Safari iOS считает высоту въезжающего address bar отдельно и 100vh выходил за экран на 60 пикселей.

  5. Affiliate-ссылки. Большая часть ссылок в старых статьях содержала прямые URL без реферальных параметров. Мы не стали править данные в Strapi пакетно (каждая статья уникальна, mass-update опасен для SEO), а сделали обёртку resolveAffiliateUrl() — на этапе рендеринга любой внешний URL заменяется на реферальный, добавляются UTM и ставится rel="sponsored nofollow" (атрибут sponsored Google ввёл в 2019 году). Двойная защита: даже если в CMS введена неправильная ссылка, на сайте будет правильная.

  6. Cookie Consent + Consent Mode v2. Российский ФЗ «О персональных данных» + GDPR требуют явного согласия на analytics-cookies. Баннер с 4 категориями (necessary, analytics, advertising, functional), Google Consent Mode v2 c дефолтным значением denied, персист в localStorage, применение согласия без релоада страницы. Чтобы GTM реагировал в worker на update согласия, gtag в partytown forward — обязательно.


Инфраструктура и деплой

Чтобы картина была полной — стек в проде:

  • Сервер: 1 VPS на Ubuntu LTS, небольшая конфигурация (статике не нужны ресурсы в runtime)

  • Web-сервер: Nginx с brotli и gzip, HSTS preload, OCSP stapling, security headers вынесены в отдельный конфиг-файл

  • CMS: Strapi 5.x под PM2, PostgreSQL, cluster mode с auto-restart

  • Build pipeline: скрипт делает fetch из Strapi, astro build, atomic-подмену dist/ в prod-папку под nginx, пинг в IndexNow

  • Sitemap: custom на TypeScript (вместо @astrojs/sitemap) — нужен был per-page priority/changefreq/lastmod и noindex для пустых каталогов

Сборка всех страниц — около 30 секунд, деплой без downtime, один недорогой VPS спокойно держит весь трафик.


Astro 5 → 6: миграция в мае 2026 без downtime

Astro 6.0 вышел в апреле 2026 и принёс две вещи, ради которых мы решились на обновление: Vite 7 (быстрее dev и build, лучше tree-shaking) и обновлённый Image Service с lazy-decoded WebP/AVIF. Ключевых breaking changes в SSG-режиме почти нет — основные поломки в экосистеме плагинов.

Что сломалось:

  • lucide-astro помечен как deprecated, официальный пакет теперь @lucide/astro. Массовый реплейс во всех компонентах — from 'lucide-astro'from '@lucide/astro'. Иконки и их props идентичны, правок в коде отрисовки не требуется.

  • Vitest 4.1+ тянет Vite 8 через transitive deps, а Tailwind 4.3 + @tailwindcss/vite не совместимы с Vite 8 на дату обновления. Решили через overrides в package.json — форсили Vite 7.3.3, Vitest зафиксировали на 4.0.5. Из всех ожидаемых вещей эта съела больше всего времени: npm резолвит зависимости из нескольких поддеревьев с разными требованиями к Vite, и без явного overrides lock-файл регулярно подтягивал несовместимую версию.

  • Astro Image Service. ProviderImage-обёртки работают как раньше, но сборка стала быстрее примерно на 30%: 32 секунды вместо 45 для всего каталога.

Итоговый стек после миграции: Astro 6.3.7 + Vite 7.3.3 + Tailwind 4.3.0 + Strapi 5.42.1 + Node 22.22.1.

Сам деплой — zero-downtime atomic swap. На сервере лежит предыдущая dist/, параллельно собирается новая в dist-new/, потом два mv и nginx -s reload. Время недоступности — меньше секунды. Поле CrUX после обновления не просело, пользователи ничего не заметили.

Главный урок, который вбили во внутренние регламенты: никогда не запускать npm run build напрямую в prod-папке. Один раз из привычки запустили — пять минут 5xx, пока сборка в живом dist/ писала файлы. Теперь все ребилды идут только через один shell-скрипт с atomic swap, остальные пути перекрыты на уровне прав.


Когда этот стек имеет смысл

Astro + Strapi + Tailwind 4 подходит, если:

  • Контентный сайт от 50 страниц: блог, каталог, обзоры, landing-ферма.

  • Контент обновляется раз в день-неделю, не в реальном времени.

  • Core Web Vitals вы воспринимаете всерьёз и вам нужны 95+ по всем категориям Lighthouse.

  • Нужна гибкость CMS для редакторов, но скорость статики для посетителей.

  • Бюджет ограничен. Один VPS вытащит сотни тысяч хитов в месяц.

Не подходит, если:

  • Нужна динамика в реальном времени: чаты, лив-обновления цен, бронирование.

  • Пользовательский контент с частыми правками. Тут лучше Next.js с ISR или SvelteKit.

  • SSR-критичные фичи: A/B-тесты на сервере, персонализация по гео- и пользовательским сегментам.

  • Команда из React-разработчиков, которым не хочется привыкать к синтаксису .astro файлов.

Главный вывод, ради которого эта статья. В 2026 году можно сделать быстрый сайт даже с полным набором маркетинговых скриптов. GTM, GA4, Вебвизор, Clarity — всё работает параллельно основной странице в Web Worker и не влияет на метрики. Маркетологи счастливы, пользователь не видит торможений, Google видит высокие CrUX. Все выиграли.


Что впереди

План на ближайшие месяцы:

  • Расширение контентной базы. Автоматизация редакторского процесса в admin-панели — подсказки по SEO-meta, structured data и полноте заполнения полей.

  • Server-Side GTM. Оставили на всякий случай — если появятся жёсткие требования 100/100 на mobile throttled, перенесём GTM на собственный поддомен через Docker. Пока это overkill.


Ссылка на проект

Рабочий пример всего описанного — easylinklife.com. Код не открыт: внутри живёт логика работы с партнёрами и партнёрские договоры. Но архитектура и подходы воспроизводимы. Если собираете похожий проект — задавайте вопросы в комментариях, отвечу по технической части. Подводных камней было больше, чем поместилось в одну статью.

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