
Когда маркетологи хотят всё: сырые данные в 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 на десктопе.
Дальше — по порядку. Почему 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 *));
Что сломалось:
-
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. -
Переменные карточек. Мы использовали CSS-переменные для темизации компонентов (
--card-bg,--text-primary). Tailwind 4 с Vite-plugin иногда tree-shake’ил неиспользуемые переменные из:root— страница собиралась, в браузере выглядела как белый прямоугольник без фоновых цветов. Пришлось явно прописать все кастомные переменные вglobal.cssи отдельно в селекторе.dark, иначе PostCSS-проход их выбрасывал. -
Классы вида
dark:bg-[#1e293b]. Работают, но#1e293b— это неslate-900. Мы осознанно используем#1e293bдля синего оттенка тёмной темы, и Tailwind 4 это не сломал, но важно следить за arbitrary values. -
Логотипы в тёмной теме. Провайдеры дают логотипы в двух вариантах — для светлого и тёмного фона. Сделали переключение через
<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.
Что пошло не так: честный блок
-
Strapi v5 Document Service. Миграция с v4 потребовала переписать все запросы.
strapi.documents()не поддерживает relations (connect/set), поэтому для связей пришлось использовать REST API. Для плагинов — отдельная история: ни один плагин не имел стабильной версии под v5, пришлось форкать и собирать из main-веток. -
Draft & Publish дублирование. Strapi v5 хранит draft и published как отдельные записи в PostgreSQL. При обновлении контента через API нужно синхронизировать обе версии, иначе данные расходятся. Столкнулись, когда изменённая цена провайдера появлялась в admin-UI, но API отдавал старую draft. Решили через фильтр
?status=publishedв сборочных фетчах. -
Пустые каталожные страницы. При очередном пересмотре каталога часть категорий услуг оставалась без карточек — страницы показывали пустой листинг. Для Google это thin content. Добавили
<meta name="robots" content="noindex">для пустых каталогов, исключили их из sitemap.xml и из внутренних линков. -
Мобильное меню. 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 пикселей. -
Affiliate-ссылки. Большая часть ссылок в старых статьях содержала прямые URL без реферальных параметров. Мы не стали править данные в Strapi пакетно (каждая статья уникальна, mass-update опасен для SEO), а сделали обёртку
resolveAffiliateUrl()— на этапе рендеринга любой внешний URL заменяется на реферальный, добавляются UTM и ставитсяrel="sponsored nofollow"(атрибут sponsored Google ввёл в 2019 году). Двойная защита: даже если в CMS введена неправильная ссылка, на сайте будет правильная. -
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, и без явногоoverrideslock-файл регулярно подтягивал несовместимую версию. -
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/