x10seo: миграция лендинга на Next.js и SEO-машина
Когда сервис по управлению поведенческими факторами начинает зарабатывать, рано или поздно упираешься в собственный лендинг. У нас он много лет жил на WordPress на старом сервере: медленный, тяжёлый в поддержке, с непредсказуемым SEO. В какой-то момент стало очевидно, что лендинг для SEO-сервиса не имеет права плохо ранжироваться — это буквально витрина компетенции. Так начался долгий проект превращения x10seo из набора WordPress-страниц в полноценную SEO-машину на Next.js. В этой статье я расскажу, что мы сделали целиком: от миграции и архитектуры до неочевидных багов парсинга ключевых слов и автоматического деплоя, который однажды задеплоил всё за нас сам.
Контекст проекта
x10seo — это сервис управления поведенческими факторами (накрутка ПФ) с интеграцией в BAS (BrowserAutomationStudio). Бэкенд — Next.js с примерно 40 API-роутами, база — self-hosted Supabase с отдельной схемой x10seo, авторизация на cookie-сессиях. У сервиса есть админка для менеджеров, чат-онбординг для добавления проектов клиентов, калькулятор стоимости, блог и глоссарий. Всё это нужно было не просто перенести, а собрать в единую систему, которая хорошо индексируется поисковиками и при этом удобна внутренней команде.
Главная цель формулировалась просто: сделать из x10seo сильную SEO-машину, сохранив при этом работающую бизнес-логику. Старый WordPress-лендинг должен был остаться доступным как референс, но новый продакшен — целиком на Next.js, развёрнутый через нашу инфраструктуру Dokploy на Hetzner.
Архитектура и принцип «деплоим одним батчем»
Одно из первых правил, которое мы зафиксировали для этого проекта: сначала завершаем все задачи, локально проверяем, и только потом — один общий деплой. Никаких деплоев по частям без явной команды. Это звучит банально, но на практике именно дробные деплои чаще всего ломают прод: ты выкатываешь половину фичи, забываешь про вторую, и пользователь ловит ошибку на стыке.
Интересно, что реальность оказалась даже опережающей планы. Когда я открыл репозиторий, чтобы выполнить большой implementation plan на 2800+ строк, выяснилось, что значительная часть работы уже в продакшене. Лендинг полностью переехал на Next.js 16 и крутится на https://x10seo.ru (200 OK), главная отдаёт корректный title и контент, публичные страницы вроде публичной оферты на месте. Урок здесь простой и важный: прежде чем переделывать, проверь, что уже сделано. Не доверяй слепо исходным предпосылкам задачи — сверь их с git-историей и живым продакшеном. Я не стал переписывать готовое, а зафиксировал факты и перешёл к тому, что реально требовало внимания.
Деплой в проекте устроен через GitHub Actions + GHCR + SSH. Каждый push в main запускает сборку Docker-образа, пуш в registry и обновление сервиса в Docker Swarm на сервере. Весь цикл — около 2-3 минут. Однажды я готовился деплоить пакет коммитов вручную и обнаружил, что GHA уже всё выкатил автоматически: последний auto-sync прошёл через pipeline, образ обновился. Вместо деплоя осталась только пост-deploy верификация — проверить миграцию в проде, зарегистрировать cron, прогнать smoke-тесты.
Про верификацию env-переменных стоит сказать отдельно. Dokploy инжектит секреты через Swarm secrets, поэтому docker exec env их не покажет — но процесс их получает. Проверять наличие секрета надо по поведению приложения, а не по выводу env. Например, cron-эндпоинт без секрета отдаёт {"error":"Unauthorized"}, а с правильным CRON_SECRET — работает. Это и есть доказательство, что секрет на месте.
Проблема: парсер ключевых слов терял частотность
Самый показательный баг этого проекта пришёл из реальной эксплуатации. При добавлении нового проекта (id 10) через бота на шаге ключевых слов и частотности система не распознала частотность. Менеджер вставил список фраз, а частоты потерялись — все 464 фразы ушли в базу с нулевой частотностью.
Я начал с диагностики, а не с гадания. Нашёл код парсинга — функцию parsePhrases в lib/parse-phrases.ts. Оказалось, парсер распознавал разделители между ключом и частотой только трёх видов: точку с запятой ;, табуляцию \t и запятую ,. А пользователи вставляют данные по-разному. Если человек скопировал список, где ключ и частота разделены просто пробелом — купить окна 1500 — то ни один из известных разделителей не находился, и вся строка целиком уходила в поле name. Частота терялась.
Задача сформулировалась чётко: бот должен принимать оба формата — и ключ;частота, и ключ частота (через пробел).
Решение: fallback на trailing-число
Корневая причина была в жёсткой привязке к списку разделителей. Решение — добавить fallback: если ни один из явных разделителей в строке не найден, проверяем, является ли последний токен (после разбивки по пробелам) целым числом. Если да — это частота, всё остальное — название ключа.
Логика before/after выглядела примерно так:
// БЫЛО: только явные разделители
function parsePhrases(line: string) {
const sep = [';', '\t', ','].find(s => line.includes(s));
if (sep) {
const [name, freq] = line.split(sep);
return { name: name.trim(), frequency: parseInt(freq) || 0 };
}
// нет разделителя -> вся строка в name, частота теряется
return { name: line.trim(), frequency: 0 };
}// СТАЛО: добавлен fallback на trailing-int
function parsePhrases(line: string) {
const sep = [';', '\t', ','].find(s => line.includes(s));
if (sep) {
const [name, freq] = line.split(sep);
return { name: name.trim(), frequency: parseInt(freq) || 0 };
}
// fallback: последний токен — целое число => это частота
const tokens = line.trim().split(/\s+/);
const last = tokens[tokens.length - 1];
if (tokens.length > 1 && /^\d+$/.test(last)) {
return {
name: tokens.slice(0, -1).join(' '),
frequency: parseInt(last),
};
}
return { name: line.trim(), frequency: 0 };
}Я написал решение в духе TDD — сначала тесты, потом проверка. Добавил 5 новых тестов в __tests__/parse-phrases.test.ts, покрывающих новый формат и edge-cases. И тут вылез поучительный момент: один существующий тест на ограничение в 500 фраз упал. Фикстура в нём генерировала строки вида phrase 0, phrase 1, phrase 2… — и новый парсер честно распознал хвостовые числа как частоту, склеив все имена в одно name="phrase". Это был артефакт самой фикстуры, а не семантическая регрессия. Я обновил фикстуру, чтобы она не триггерила новый парсер искусственно, и все 166 тестов прошли.
Это важный нюанс при изменении парсеров: расширяя распознавание форматов, ты неизбежно меняешь поведение на данных, которые раньше попадали в общий случай. Старые тесты с «мусорными» фикстурами начинают падать не потому, что код сломан, а потому что фикстуры были написаны под старую, более узкую логику. Различать эти два случая — навык, который экономит часы.
После фикса я обновил и UI-подсказки в чат-онбординге и в визарде менеджера: раньше там упоминался только формат через ;, теперь явно указано, что пробел тоже работает. Подсказка в интерфейсе должна соответствовать тому, что код реально принимает, иначе пользователь снова вставит «неправильный» формат.
Билд прошёл чисто, typecheck зелёный, коммит ушёл в main, GHA собрал и задеплоил за пару минут. Smoke-проверка прода подтвердила: и ключ;частота, и ключ частота теперь распознаются и в чат-онбординге, и в визарде.
Backfill частотности через WordStat
Фикс парсера решал проблему для будущих проектов, но у проекта 10 уже лежали 464 фразы без частотности. Их надо было добить. Я проверил БД: действительно, все 464 фразы с frequency = 0.
Вместо ручного перепарсинга я написал одноразовый скрипт-обновлятель на основе существующей библиотеки lib/xmlriver.ts — она ходит в WordStat через сервис XMLRiver и возвращает частотность по ключу. Стоимость операции была заранее посчитана: ~12 рублей за все 464 запроса. Это, кстати, отдельное правило из наших уроков — любую платную автоматику считай заранее: стоимость одного вызова × количество × частота. Здесь сумма копеечная, но привычка обязана работать на любом масштабе.
Скрипт запускался последовательно, примерно по секунде на фразу — около 8 минут на всё, разбито на 10 батчей. Я отслеживал прогресс через монитор: batch 4/10 — updated 144, batch 5/10 — 169, batch 6/10 — 194, и так далее. Часть фраз помечалась как skipped — это нормально: по ним WordStat либо не вернул данных, либо частота нулевая. Процесс жил стабильно (TCP-сессия с XMLRiver в состоянии sleeping на IO между запросами), и backfill доехал до конца.
Отдельно отмечу решение по самому скрипту: это just-once утилита, и держать её в репозитории как боевой код смысла нет. Такие одноразовые скрипты лучше либо не коммитить вовсе, либо явно помечать как одноразовые, чтобы через полгода никто не принял их за часть пайплайна.
Сопутствующие фичи: debounced regenerate и notify-seo
Помимо парсера, в продакшен в рамках этого же цикла уехало несколько SEO-ориентированных механизмов, которые и делают x10seo «машиной». Главный — debounced file regenerate: когда контент проекта меняется, файлы экспорта не перегенерируются на каждый чих, а с дебаунсом, учитывающим хэш содержимого. Если хэш не изменился — регенерация не запускается. Это снижает нагрузку и не плодит лишних коммитов. Под это была миграция, функция scheduleRegenerate, cron-эндпоинт /api/cron/regenerate-pending, override для админа и бейдж статуса на странице /seo.
Второй кусок — синхронизация статуса между сущностью проекта и file_exports.seo_status, плюс фикс bootstrap-а при null хэше (классический edge-case первого запуска, когда сравнивать ещё не с чем). И третий — библиотека notify-seo с уведомлениями на четыре ключевых события, завязанная на pause/resume логику.
Про cron стоит подчеркнуть нашу больную тему: любой cron, который дёргает платное API или LLM, обязан иметь ранний выход, если работы нет. Эндпоинт regenerate-pending именно так и устроен — он отрабатывает быстро и ничего не делает, если очередь пуста. Мы это правило выстрадали на другом проекте, где LLM в systemd-таймере без guard'а молотил каждую минуту 11 часов подряд и сжёг ощутимую сумму. С тех пор early-exit до первого платного вызова — обязательное условие code review, а не предположение.
Результат
Итог по продукту получился цельным. Лендинг x10seo полностью переехал с WordPress на Next.js 16 и работает в продакшене на собственной инфраструктуре (Dokploy + Docker Swarm на Hetzner) с автоматическим деплоем за 2-3 минуты на каждый push. Парсер ключевых слов теперь принимает оба распространённых формата ввода — через ; и через пробел — что убрало целый класс ошибок при онбординге новых проектов клиентов. Все 464 «осиротевшие» фразы проекта 10 получили частотность через WordStat за ~12 рублей и 8 минут. Тестовый сьют — 166 зелёных тестов, билд чистый. SEO-механика (дебаунс-регенерация, синхронизация статусов, уведомления) работает в проде и проверена post-deploy.
Выводы
Первый и главный урок этого проекта — проверяй реальность перед тем, как переделывать. Промпт задачи говорил «перенеси и доделай», а git-история и живой прод показывали, что 80% уже сделано и работает. Если бы я доверился формулировке задачи, я бы переписал готовый код, внёс регрессии и потерял день. Сверка с продакшеном заняла десять минут и сэкономила гораздо больше.
Второй урок — про парсеры пользовательского ввода. Люди вставляют данные в том формате, в каком им удобно, а не в том, который ты предусмотрел. Жёсткий список разделителей всегда рано или поздно встретит формат, которого в нём нет. Правильная стратегия — не плодить ещё больше явных разделителей, а добавить разумный fallback на структуру данных (здесь — «последний токен является числом → это частота»). И обязательно синхронизировать UI-подсказки с тем, что код реально принимает: иначе пользователь снова и снова будет вставлять «неправильный» формат, который на самом деле должен работать.
Третий урок — про автоматизацию и деньги. Любой фоновый процесс, дёргающий платное API, должен иметь посчитанную стоимость и ранний выход при отсутствии работы. Backfill частотности на 12 рублей — мелочь, но привычка считать должна срабатывать на каждом скрипте, потому что однажды это будет не 12 рублей, а счёт на тысячи. Cron без guard'а — это не экономия времени, а мина замедленного действия.
Четвёртый, более общий вывод — про дисциплину деплоя. Правило «сначала всё доделать локально, проверить, потом один батч» спасает от поломок на стыках недоделанных фич. А когда деплой автоматизирован через GitHub Actions, фокус инженера смещается с «как выкатить» на «как убедиться, что выкатилось правильно»: миграции применены, cron зарегистрирован, smoke-тесты зелёные, секреты на месте (проверенные по поведению, а не по env). Хороший CI/CD не отменяет верификацию — он делает её главной работой человека.
Полезные ссылки по технологиям из статьи: документация Next.js, Supabase Docs, GitHub Actions, Docker Swarm mode и Vitest для тестов парсера.

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.
Связанный проект
X10 SEO →