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 и взвешиваете переезд, я разберу ваш случай и покажу, как посчитать, стоит ли оно того.
Об авторе
Я Яков Радченко, founder ETERN8 - бутика индивидуальной веб-разработки. Делаем интернет-магазины, маркетплейсы и бизнес-порталы на Next.js с фиксированной ценой и сроком. Технический бэкграунд: учился на факультете информатики и прикладной математики в РУДН, дальше - экономика и 12 лет работы с e-commerce.
Telegram: @yakov_etern8.
Wiggin2014
Че-то не понял. 2016-2026 - это 10 лет, ~3000 заказов за 10 лет - примерно 1 заказ в день. Ну или 2, если первые 5 лет не работали. Стоило такое городить ради 2-3 заказов в день?
yakov_etern8 Автор
Справедливо на первый взгляд, но тут смешались две разные метрики.
3 123 заказа - это не «оборот в день», а объём перенесённой истории: сколько заказов за всё время жизни магазина именно на Insales мы вытащили и переложили в свою БД без потерь и со связями заказ↔товар↔клиент и сохранёнными статусами.
Метрика про чистоту миграции, а не про то, как магазин торгует. Смысл переезда тоже не в объёме, а в стоимости владения и контроле. Подписка InSales с нужными платными приложениями выходила в 2 300–10 400 ₽/мес, своя инфраструктура на VPS меньше 1 000 ₽/мес.
На горизонте года это окупается даже при скромном потоке заказов, а сверху полный контроль над кодом, данными и SEO, которого на платформе нет.
И историю тащили не ради красивой цифры: на реальных датах и связях сразу заработали win-back-письма и рекомендации: с «чистого листа» они бы не ожили.
Да, магазин небольшой, и это наш собственный бренд - поэтому миграцию и обкатывали «на себе», без риска для клиента.
Статья именно про инженерную часть: ETL из UTF-16, 301-редиректы, свой движок - а не про обороты.