МаркетплейсподержаннойхоккейнойэкипировкидляСербиизатринедели
Три недели, бриф от одного хоккейного клуба, Nuxt 4 на Cloudflare Pages, edge-ISR поверх Supabase, кеш живёт вечно и сбрасывается только на запись.
Сегодня выкатили shop.propeleri.rs — маркетплейс подержанной хоккейной экипировки для Сербии, сделанный для HC Propeleri Novi Sad на Nuxt 4 и Cloudflare Pages с edge-ISR поверх Supabase. Три недели part-time параллельно с основным сайтом клуба. Этот пост — инженерный разбор: что делает продукт, как мы его закешировали, и почему этот паттерн переиспользуется на любом нишевом read-heavy маркетплейсе.
Задача мелкая, но реальная. Хоккей — нишевый спорт в Балканах, и играют здесь почти исключительно взрослые: rec-лига, поздно начавшие, возвращающиеся. Это намеренно senior-only рынок, детской пирамиды нет. Полная экипировка от €800, клюшки ломаются, новички переходят с прокатной экипировки на свою, кто-то завязывает или переезжает и разгребает подвал — а в регионе просто не было места, где локально купить или продать б/у. Продавцы сидели в Viber-группах и на Facebook Marketplace, где объявления умирают за неделю и нельзя отфильтровать по жёсткости клюшки или размеру конька. Клуб хотел одно место, которое местные взрослые игроки действительно будут открывать.
Что собрано: каталог с иерархическими категориями (клюшки, коньки, шлемы, перчатки, защита, вратарское, услуги), фильтры по городу, состоянию, цене и атрибутам по категории (жёсткость, загиб, длина шафта и т. д.), полноэкранные галереи фото с Product-schema для SEO, и гейт "Показать контакты" за логином — это и защищает продавцов от парсеров, и даёт честную метрику просмотров на каждое объявление. Сверху: одноразовые офферы на торгуемых объявлениях, избранное, сохранённые поиски с in-app нотификацией и web-push на новые совпадения, история снижения цены, бандл-объявления (продать полный комплект одним лотом с атрибутами по элементам), блок продавца и жалоба на объявление с админ-очередью модерации. Три языка: сербский по умолчанию в корне, русский и английский на /ru и /en. PWA с возможностью установки, View Transitions API для мягких переключений, web push через VAPID.
Самое интересное инженерное решение — кеш. Первая версия была обычным SSR-на-edge Nuxt-приложением. Листинги рендерились нормально, но каждый прямой заход дёргал свежий рендер в Supabase. Для низкотрафикового маркетплейса это расточительно и плохо масштабируется по регионам. Нужен был кеш. Вопрос — какой.
Два очевидных ответа отпали. SSG (nuxt generate) — мимо: каждое новое объявление это ребилд, тупик для маркетплейса. Stale-while-revalidate с коротким TTL соблазнял, но был неправ: он триггерит фоновый ре-рендер на каждое истечение TTL независимо от того, поменялись ли данные. На низкотрафиковом сайте это значит, что большая часть рендеров — мёртвый труд. Мы хотели "кеш живёт до того момента, как реально поменялись данные, и не дольше".
Пришли к Nitro ISR без TTL плюс явной инвалидации на каждой мутации. routeRules в nuxt.config.ts помечает /listings/:slug, /u/:slug, /categories и /services как isr: true. /help и /terms — prerender: true. Всё авторизованное — /my/**, /favorites, /notifications, /sell/** — остаётся полным SSR. Маршрут рендерится один раз, сидит в edge-кеше Cloudflare сколько угодно, и слетает только когда объявление реально меняется.
Сторона инвалидации — apps/shop/server/utils/cache.ts, экспортирует три хелпера: invalidateListing(slug, sellerSlug?), invalidateSeller(slug), invalidateCatalog(). Это тонкие обёртки над useStorage('cache').removeItem(...). Каждая серверная мутация, влияющая на отрендеренную HTML-страницу, в конце успешной ветки зовёт нужный хелпер — публикация, смена статуса, снижение цены, добавление/удаление/переупорядочивание фото, добавление/удаление позиции бандла, патч профиля. Единственная мутация, идущая прямо из браузера в Supabase (главная SellForm.submitEdit, пишущая через RLS), после успеха стучится в крошечный owner-authed POST /api/listings/:id/invalidate-cache. Ментальная модель в одной фразе: кеш живёт пока не сдвинулись данные, и каждый путь кода, который двигает данные, обязан сказать об этом кешу.
Паттерн оказался тихим и скучным в самом хорошем смысле. Один edge-регион, обслуживающий тысячи хитов каталога в день, дёргает Supabase ровно один раз на мутацию, а не на посетителя. Без TTL — у продавцов нет "подожди минуту и обнови", их правка живёт в момент возврата ответа. И поскольку инвалидация явная, режим отказа громкий: забыл позвать хелпер на новом пути мутации — баг очевиден на ближайшем тесте и легко правится. Этот размен мы бы взяли в любом маркетплейсе, где листинги read-heavy, а запись редкая.
Вторая нетривиальная вещь — не утечь user-aware UI в кешированный HTML. Страница объявления рендерит разные контролы в зависимости от смотрящего: владелец видит Edit и Price Drop, залогиненный не-владелец — Reveal Contacts и Make Offer, аноним — CTA на логин. Если что-то из этого попадает в SSR-проход, первый просмотревший зашьёт свою идентичность в кеш для всех следующих. Лекарство — обернуть все четыре блока в <ClientOnly>. SSR-проход теперь идентичен для всех; per-user UI догидрирует на клиенте. Очевидно в ретроспективе, легко пропустить на скорости.
Короткая ремарка по модели данных. Около пятнадцати таблиц: listings, listing_photos, listing_attributes, listing_items (дети бандла), listing_offers (один раунд, опциональное message, без треда), listing_favorites, listing_reports, categories (иерархически), category_attribute_schemas (поля по категории), saved_searches, contact_views, user_blocks, services, service_categories, push_subscriptions. У категорий — JSON attribute_schema, так что добавить "Toe pattern: квадратный/круглый" в "Клюшки" это одна строка в БД, без кода. Сессии host-scoped — Supabase-проект общий с propeleri.rs, но cookie не общий: ранее попытка cookie на родительский домен утекла между приложениями и сломала гидратацию на сайте клуба.
Что вырезали на запуск: рейтинги продавцов и поток платежей. Ничего из этого не блокирует пользу — после reveal-контактов разговор уходит в телефон, Viber или Telegram, и локальным покупателям этого хватает, — но оба пункта в роадмапе после первого месяца наблюдения. Что дальше: настройка частоты дайджестов сохранённых поисков по реальному сигналу, лёгкий бейдж верификации продавца, и развёртывание той же архитектуры под первое не-хоккейное сообщество, которое уже спросило (мотокросс, потом б/у лыжи). Паттерн маркетплейса — read-heavy, write-rare, edge ISR с явной инвалидацией — переиспользуется на нишевых спорт-комьюнити, и это, кажется, и есть продукт, который мы случайно собрали.