IWANT — наш собственный fashion-магазин. Несколько лет он жил на InSales: на старте это правильный выбор: быстро, без разработки, всё из коробки. Но в какой-то момент мы уперлись в потолок платформы: каждый нужный модуль — это либо платное приложение, либо «так нельзя». Мы посчитали и решили перевезти магазин на собственный движок.
Это не история «платформы плохие, пишите своё». Это разбор конкретного переезда: что переносили, как устроен ETL из выгрузок InSales, на каком стеке собрали и почему именно на нём, какие модули пришлось писать самим, как прошёл катаут без простоя и кому такой переезд реально нужен, а кому нет.
Почему ушли с InSales
InSales нормальная платформа. Проблема не в ней, а в модели: ты арендуешь чужой движок и живёшь по его правилам.
Что упиралось в потолок:
1) Нужные функции (серверная корзина между устройствами, кастомная логика писем, кошелёк с кэшбэком) либо платные приложения, либо невозможны в принципе.
2) Любая нестандартная доработка фронта ограничена шаблонной системой.
3) Данные и логика живут в чужом контуре и чем дольше тянешь, тем дороже потом мигрировать.
В какой-то момент аренда платформы со всеми приложениями стала стоить дороже, чем контроль над собственным кодом: тариф InSales с нужными приложениями – это 2 300-10 400 ₽/мес в зависимости от набора. Это и был триггер.
Что переносили
— Каталог товаров со структурой категорий и вариаций.
— 3 123 заказа за 2016-2026 всю историю, а не «начать с чистого листа».
— Клиентов и их историю покупок (включая накопленную лояльность, её нельзя было обнулить).
— SEO-наследие: старые URL, чтобы не потерять позиции.
Главное требование к миграции: ноль потерь данных и сохранение SEO. Падение трафика после переезда — самый частый и самый болезненный провал таких проектов.
ETL: переносим каталог и историю заказов
Каталог и заказы переносили двумя разными путями и засада была ровно в одном месте.
Каталог пришёл из InSales структурированно (товары, вариации, категории, остатки), мы прогоняли его через Zod-схемы и заливали идемпотентным upsert: товары и категории по slug, вариации по sku. Картинки дедуплицировали по MD5: если байты совпали с тем, что уже лежит в S3, повторная заливка пропускается. Это важно, когда импорт 3 700+ товаров с картинками идёт часами и его приходится перезапускать.
Заказы — вот где была первая засада: кодировка. Экспорт заказов InSales отдаётся в UTF-16 LE, tab-separated, ещё и с BOM. Наивное чтение ломает кириллицу. Поэтому декодируем явно и снимаем BOM:
const buffer = readFileSync(path.resolve(csvPath))// Экспорт заказов InSales: UTF-16 LE, tab-separated, с BOMconst text = buffer.toString("utf16le").replace(/^\uFEFF/, "")const rows = parseDelimited(text) // свой RFC4180-парсер, разделитель — таб
// Экспорт заказов InSales: UTF-16 LE, tab-separated, с BOM
const text = buffer.toString("utf16le").replace(/^\uFEFF/, "")
const rows = parseDelimited(text) // свой RFC4180-парсер, разделитель — таб
Стандартный CSV-парсер тут не подходит: поля бывают в кавычках с табами и переводами строк внутри, поэтому делимитер-парсер пришлось написать руками. Вторая засада — это структура: один заказ размазан по нескольким строкам. N строк товаров и отдельная строка «Доставка» делят общий №, а пустая строка разделяет заказы. Собираем заказ обратно, группируя по номеру:
const orders = new Map<string, ParsedOrder>()for (const row of rows.slice(1)) { const n = get(row, "№") if (!n) continue // пустая строка-разделитель const order = orders.get(n) ?? createOrder(row) orders.set(n, order) const title = get(row, "Наименование товара (услуги)") if (title === "Доставка") { order.shipping += num(get(row, "Сумма для получения")) } else if (title) { order.items.push(parseItem(row)) // Артикул → ProductVariant.sku }}
for (const row of rows.slice(1)) {
const n = get(row, "№")
if (!n) continue // пустая строка-разделитель
const order = orders.get(n) ?? createOrder(row)
orders.set(n, order)
const title = get(row, "Наименование товара (услуги)")
if (title === "Доставка") {
order.shipping += num(get(row, "Сумма для получения"))
} else if (title) {
order.items.push(parseItem(row)) // Артикул → ProductVariant.sku
}
}
Заливку сделали идемпотентной, чтобы повторный прогон не задваивал историю. Каждый перенесённый заказ получает номер IS-<№> (новые заказы в магазине нумеруются IW-YYMM-…, так что коллизий нет), а клиент привязывается по e-mail, иначе по нормализованному телефону:
const orderNumber = `IS-${order.num}`if (existing.has(orderNumber)) { skipped++; continue } // уже импортированconst customerId = byEmail.get(order.email.toLowerCase()) ?? byPhone.get(normalisePhone(order.phone)) ?? null
if (existing.has(orderNumber)) { skipped++; continue } // уже импортирован
const customerId =
byEmail.get(order.email.toLowerCase()) ??
byPhone.get(normalisePhone(order.phone)) ?? null
Это потом окупилось: на перенесённой истории заказов (с реальными датами с 2016 года) сразу заработали win-back-письма и рекомендации. Им было на чём учиться с первого дня, а не с нуля.
Катаут без простоя
Переключение боялись больше всего: магазин не должен встать ни на минуту. Сделали blue-green: новый движок поднимался рядом с боевым, прогонялся на проде, и только потом переключали трафик.
Порядок деплоя жёсткий: prisma migrate deploy прогоняется до сборки (схема БД всегда впереди кода), затем собирается standalone-бандл, обновляется статика и перезапускается сервис. Письма идут через собственную очередь с идемпотентными ключами — повторный прогон джобы не задваивает заказ или уведомление. В день катаута магазин работал без перерыва, заказы шли.
Стек и почему именно он
— Next.js 15 (App Router, RSC, Server Actions) — серверный рендеринг каждой карточки, а не только главной. Для SEO это принципиально.
— Prisma + PostgreSQL — одна база как источник правды для каталога, заказов, клиентов и контента.
— Tailwind + shadcn/ui — быстрая и предсказуемая вёрстка без своего дизайн-зоопарка.
— Хостинг: собственный VPS (Next standalone за Nginx, деплой скриптом), изображения на S3 и отдача через CDN.
— Sentry с первого дня — чтобы ловить ошибки на живом трафике, а не по жалобам.
Почему self-host, а не Vercel или облачный BaaS: контроль над данными (для РФ-аудитории это в том числе вопрос 152-ФЗ), предсказуемая стоимость и отсутствие привязки к ещё одной платформе. Инфраструктура в итоге выходит около 1000 ₽/мес. Эо меньше, чем стоила подписка платформы с приложениями.
Модули, которые на InSales были платными мы собрали как first-party
Это главная причина переезда. То, за что платформа берёт отдельно или не даёт вообще, теперь часть нашего кода:
1) Серверная корзина живёт на сервере, синхронизируется между устройствами и не теряется при смене девайса.
2) Retention-письма (welcome, брошенная корзина, win-back, «снова в наличии») — своя очередь и cron, с учётом согласий по 152-ФЗ.
3) Подарочные карты, кошелёк и кэшбэк — отдельная сущность транзакций в БД с полным жизненным циклом.
4) Чекаут под РФ — ЮKassa (карта/СБП) с фискальным чеком по 54-ФЗ, СДЭК (курьер, ПВЗ, расчёт тарифа), промокоды.
5) Отзывы с проверкой покупки, подбор размера, блог на собственной CMS, YML-фид для маркетплейсов.
Каждый модуль — это код в репозитории, а не аренда чужой фичи. Нужна новая логика, пишем, а не ждём, разрешит ли её платформа.
Сверх стандартного магазина добавили IRIS — ИИ-стилиста на GigaChat (RU-hosted, 152-ФЗ). Он собирает образ прямо на сайте, отвечает строгим JSON и — важная деталь — валидирует подобранные вещи против реального каталога, поэтому не «галлюцинирует» товарами, которых нет.
Аналитика: написали свою вместо чужих счётчиков
Раз уж данные теперь в нашем контуре, логично и считать их у себя. Сделали две системы.
Бизнес-аналитика (/admin/analytics) на RSC, без внешних сервисов: воронка «корзина → заказ», средний чек, повторные покупки, брошенные корзины в рублях, бестселлеры и неудовлетворённый спрос. Что искали и не нашли in-stock. Это не дашборд ради дашборда, а данные, по которым реально решаем, что закупать и что чинить в воронке.
И отдельно — собственный cookieless-трекинг трафика, мини-Plausible. Маячок на sendBeacon шлёт POST /api/track, а посетитель считается как HMAC(secret, дата | ip | ua) с ротацией соли в полночь. На выходе аналитика по источникам, устройствам и страницам без единого cookie и без персональных данных: согласие по 152-ФЗ не требуется, DNT уважается, боты и /admin исключены. Никакой зависимости от внешнего счётчика и его политики и баннер согласия не блокирует сбор базовой статистики.
SEO: чтобы переезд не уронил трафик
Это была критичная часть. Терять накопленные позиции мы не имели права.
1) Перенесли 7 636 страниц с полным покрытием 301-редиректами, без потери позиций.
2) Актуальный sitemap из БД — 3 748 URL: служебный InSales-бэклог и out-of-stock сознательно не отдаём в индекс, sitemap собирается из базы, а не руками.
3) 530+ 301-редиректов, построенных из реальной аналитики Метрики и GSC, а не «на глаз»: приоритет отдавали URL, которые реально приносили трафик.
4) Серверный рендеринг каждой карточки + Schema.org-разметка (Product, Offer, Breadcrumb, FAQ) на ключевых шаблонах.
5) Динамические OG-изображения.
AEO: чтобы находил не только Google, но и AI
Отдельно заложили готовность к AI-поиску: в 2026 это уже не «на будущее»:
— Динамический llms.txt: бренд-нарратив, доставка, категории и до 24 in-stock товаров из БД плюс гайдлайны для AI (проверять цены, не выдумывать).
— Страница /about-for-ai — человекочитаемый профиль бренда с Organization JSON-LD.
— robots не блокирует AI-краулеры.
— RU/EN с hreflang, причём EN — это полноценно индексируемая локаль, а не noindex-зеркало, как в большинстве проектов.
Про производительность. Честно.
Отдельная история — это mobile-LCP. После катаута первый замер показал 11.8с, и виновником оказался не hero-баннер, а карусель «Это может вас заинтересовать» глубоко под фолдом: она грузила первые фото с fetchPriority=high и отбирала канал у hero-картинки на throttled-4G. Сняли приоритет с внефолдовых блоков, убрали дублирующий preload. Лучшие прогоны упали до 3.2с, CLS на блоге свели с 0.321 к нулю. SEO и Best-practices по Lighthouse — 100, но над стабильностью LCP на холодном кэше работаем дальше. Это процесс, а не «100 из коробки».
Кому такой переезд нужен, а кому нет
Честно: переезжать с платформы стоит не всем.
Оставайтесь на платформе, если она вас не ограничивает: небольшой каталог, стандартный сценарий продаж, нет потребности в своей логике. Своя разработка тогда — это лишние расходы и лишняя ответственность.
Думайте о переезде, если: платформа не даёт нужных модулей (или даёт только платно), вы переросли шаблон, хотите контроль над данными и кодом, или подписка с приложениями превратилась в ощутимую статью расходов.
Мы прошли этот путь на собственном магазине, поэтому считаем не по учебнику, а по своим граблям. Если вы на InSales, Битрикс, Tilda или Shopify и взвешиваете переезд, я разберу ваш случай и покажу, как посчитать, стоит ли оно того.
ссылка на оригинал статьи https://habr.com/ru/articles/1045574/