Технический стек
Репозиторий, слой данных, ежедневный синк, рендеринг, SEO-слой, медиа, деплой и техдолг.
TL;DR
Next.js 15 на App Router, React 19, TypeScript, PostgreSQL 16 в докере, Node 22 одним процессом под systemd, nginx впереди. Семь зависимостей в проде: next, react, react-dom, pg, rrweb, rrweb-player, isbot. Ни CSS-фреймворка, ни ORM, ни библиотеки локализации — 1 715 строк собственного CSS, свои SQL-запросы, свой словарь.
Рядом с сайтом — питоновый конвейер синхронизации каталога и 397 тестов на node:test. 170 коммитов с 31 августа 2026 года, 31 260 строк TypeScript.
Стек и архитектура
Репозиторий
src/app — маршруты, src/lib — 96 модулей предметной логики с тестами рядом, src/components — 48 файлов сайта и админки, db — схема, миграции и питоновый синк, scripts — 32 инструмента, content — данные под git, docs — 18 разборов подсистем.
Слой данных
22 таблицы в схеме public и 7 в analytics. Аналитика вынесена в отдельную схему намеренно: переимпорт контента делает drop table cascade, и статистика уехала бы вместе с каталогом.
Ключевая таблица — unit_ids: наши номера лотов с датой первой встречи и датой последней правки. Её единственную синк не пересоздаёт. Номер выдаётся один раз и не восстанавливается ниоткуда: потеряем таблицу — потеряем и lastmod в карте сайта, и связь с проиндексированными страницами.
ORM нет, запросы каталога написаны руками, и у них есть инвариант: order by u.sheet, u.sheet_row, u.id обязателен — по этому порядку раздаются суффиксы повторяющихся слагов, и без сортировки один и тот же объект после переимпорта получал бы то -2, то адрес без суффикса вместе со всей накопленной по адресу статистикой.
Ежедневный синк
Утренний перенос — служба на Python, таймер в 10:00 по Бангкоку, четыре шага: проверка структуры всех листов источника, подготовка плана со снимком и карантином, применение плана одной транзакцией под блокировкой таблиц, сброс кэша сайта через закрытый секретом HTTP-эндпоинт.
Три вещи, ради которых это переписано с первой версии:
— Одна транзакция вместо truncate. Первая версия чистила таблицу лотов и заливала заново; сбой на середине оставлял пустой каталог.
— Устаревший план не применяется. Состояние базы сверяется повторно под блокировкой, а вчерашнее разрешение не переносится на сегодняшнюю книгу.
— Откат — штатная операция. Откатывается последний применённый план и только при совпадении контрольного состояния базы.
Рендеринг и кэш
Листинги динамические — они читают параметры запроса. Главная, подборки, гиды и страницы комплексов живут на ISR с пересчётом раз в час. Слой настроек витрины кэшируется на 30 секунд и сбрасывается записью.
Тонкое место нашлось здесь: чтение, стартовавшее до правки, дописывало снимок со свежим временем и отменяло инвалидацию — до 30 секунд витрина показывала доправочное состояние, и ISR успевал его зафиксировать. Теперь инвалидация двигает счётчик поколений, а читатель с разошедшимся поколением перечитывает до возврата результата.
Маршруты и SEO
Адрес лота — шесть сегментов: язык, тип сделки, город, район, тип жилья, слаг. Гигиена адресов вынесена в middleware: lowercase по декодированному пути, 410 на мёртвый раздел донора, 301 на пустые поля GET-формы и ?page=1, 308 на раздел без языкового префикса.
Карта сайта разбита на четыре: страницы, листинги, объекты, комплексы — 1 958 адресов, то есть 979 страниц в двух локалях. Тонкие страницы без фотографий и описания в неё не попадают: адрес в карте сайта при noindex в шапке — прямое противоречие. lastmod ставится только у лотов, чью правку мы застали.
Разметка — 23 типа schema.org. Пороги индексации листингов вынесены в отдельный модуль и проверяются тестами: город от пяти лотов, остальное от трёх, листинг без района — всегда noindex.
Медиа
Фотографии не отдаются из Next. Исходники обрабатываются в sharp в четыре ширины (400/800/1200/1600) и раздаются nginx с годовым иммутабельным кэшем: 70 076 файлов webp на 7,6 ГБ, 22 508 строк с путями в базе.
Выбор обложки и разметка кадра (съёмка / рендер / планировка) лежат в git, а не в базе: правка приходит с доказательством и откатывается одним git revert. Разъезд стережёт проверка данных.
Аналитика
События append-only, счётчиков-агрегатов в таблицах нет — всё выводится SQL-ом, поэтому повтор доставки не может испортить цифру. Идемпотентность на трёх уровнях: uuid события, ключ навигации на клиенте (гасит двойной прогон эффекта в Strict Mode) и uuid заявки.
Вовлечённое время — сумма интервалов между отметками активности, каждый обрезан 20 секундами: забытая на два часа вкладка не превращается в два часа чтения. Приём событий всегда отвечает 204 — телеметрия не имеет права ломать сайт, — но порядок проверок внутри значим: кука отказа, соль для хеша адреса, рейт-лимит, детектор ботов, Origin, потолки размера тела.
Хостинг и деплой
Один VPS: 4 ядра, 16 ГБ, Docker с Postgres, Node под systemd, nginx с HTTP/2 и HSTS, Let's Encrypt с автопродлением. Бэкап раз в трое суток на два внешних хранилища.
Деплой — пуш в main. Два независимых триггера: вебхук GitHub с проверкой подписи и страховочный крон раз в минуту, сравнивающий HEAD с origin/main. Оба зовут один скрипт под flock: fetch, reset, установка зависимостей, сборка, рестарт, smoke-запрос главной. Провал smoke останавливает выкатку.
Что бы я переписал
— Один процесс Next — это потолок. Страница комплекса стоит около 1,4 секунды процессора. Обход всех адресов карты сайта в темпе 400 запросов в минуту положил очередь: nginx отдал 148 ответов 504. Кэша страниц и ограничения очереди в nginx нет до сих пор.
— Рейт-лимит живёт в памяти процесса. Пока инстанс один, это работает. Второй воркер — и лимит нужно выносить наружу вместе с ISR-кэшем и сбросом поколений.
— Часть контента не воспроизводится из репозитория. Тексты, обложки в базе и видео восстанавливаются только из дампа раз в трое суток. Обложки и разметка кадров уже уехали в git, тексты — ещё нет.
— Идентичность лота держится на ключе «слаг проекта + код». У 15 лотов кода нет вовсе, у 5 ключей он неоднозначен. Настоящее решение — стабильный номер в самой таблице поставщика; остальное здесь компенсация чужой схемы.
— Проверка данных не запускается сама. Одиннадцать проверок, от пропавшей цены до дубля обложки, гоняются руками. Им место в таймере рядом с синком.
— Английский корпус гидов разошёлся с каталогом. 86 чисел в английских текстах отстали от базы. Конвейер сверки есть, прогнать его по английским гидам ещё предстоит.