ESPNMMA pound-for-pound rankings: Natalia Silva steps up, stands out from crowdESPN DeportesMessi y Cristiano Ronaldo, diferentes hasta para irseThe Jerusalem PostYemen displacement surges past 200,000 following fresh Houthi offensive, UN migration body warnsColliderAlfred Hitchcock’s ‘Psycho’ Officially Returns to Alamo Drafthouse Theaters With Eric Nam [Exclusive]South China Morning PostSingapore’s air pollution worst in world as haze shuts Malaysia schoolsZDF heuteEntdecken Sie das ZDF-NachrichtenstudioAnime News NetworkManga Up! Global Adds Magical Girl Recruiter Puicho!, Bitter Knight in My Sweet Café, 3 More MangaThe RegisterWhen one datacenter is no longer enoughDeadlineToby Emmerich Joins FilmNation Entertainment’s Infrared As Consultant
The Daily Newsstand · Free, Always
Wednesday, October 7, 2026

Переезд с Webflow на Next.js по одной странице: Payload CMS, next-intl и проверки в CI

Translate

Португальскую версию нашего сайта мы собирали два месяца. Переключаешь локаль в дизайнере Webflow, правишь текст в блоке, публикуешь, идёшь к следующему. Иногда после публикации на проде оставался старый текст, и блок приходилось проходить заново.

Последние месяцы я переношу этот сайт на Next.js 15 и Payload CMS. Переезд на финишной прямой: большая часть страниц уже работает на Next, остальные идут по тому же конвейеру. Ниже про то, как он устроен, и про грабли, на которые я наступил по дороге. Компанию не называю, это мой текущий работодатель, поэтому код в статье обезличен.

Немного контекста. Я фулстек-разработчик, в вебе семь лет. Сайт веду один, макеты приходят из Figma от дизайнеров. К началу переезда в Webflow было больше 800 компонентов, около сотни статических страниц, несколько CMS-коллекций (блог, новости, упоминания в прессе, changelog) и пять языков: английский, русский, испанский, португальский и французский. Трафик 100–150 тысяч пользователей в месяц.

Из чего складывался счёт

Webflow для такого сайта был хорошим выбором два года назад. CMS встроенная, хостинг, CDN и SSL работают из коробки, а лендинг в визуальном редакторе собирается в разы быстрее, чем руками. Проблемы начинаются с ростом: локализация вручную, редактор тормозит на сотнях компонентов, нет ни ревью кода, ни тестов.

Деньги тоже сыграли роль. Весной 2026 года Webflow обновил тарифы, и при нашем трафике главной статьёй стал bandwidth:

Статья

В месяц

Site plan: хостинг одного сайта и 50 ГБ трафика

$39

Трафик сверх 50 ГБ, ступень +450 ГБ

$260

Локализация на пять языков

$140

Seats: full и limited ($648 в год)

$54

Workspace Core ($228 в год)

$19

Второй сайт ($168 в год)

$14

Итого

$526

Счёт Webflow за месяц без налогов. Годовые платежи пересчитаны в месячные

Счёт Webflow за месяц без налогов. Годовые платежи пересчитаны в месячные

За трафик мы платили почти в семь раз больше, чем за сам тариф. Суммы за seats, локализацию и второй сайт взяты из наших счетов, остальное есть на странице тарифов Webflow.

Почему не переписать всё разом

Сотня страниц на пяти языках плюс CMS — это несколько месяцев работы одного человека. Переписать всё и переключить домен в один день значило бы на эти месяцы заморозить контент, а сайт всё это время живёт: выходят новые лендинги и вебинары.

Поэтому переезд постраничный, по схеме strangler fig. Новое приложение растёт рядом со старым и забирает у него страницы по одной. В середине августа на Next работали три страницы, к началу октября 57.

Next.js задеплоен на Webflow Cloud. Это хостинг Webflow для приложений на Next.js, Astro и других фреймворках. Next там собирается через OpenNext под Cloudflare Workers. Приложение смонтировано на тот же домен под /app (basePath: '/app' в next.config.ts). Корень домена и страницы, которые ещё не переехали, отдаёт Webflow. Когда страница переезжает, в Webflow появляется 301 со старого адреса на новый.

Как устроен сайт на время переезда: один домен, два приложения

Как устроен сайт на время переезда: один домен, два приложения

Из-за этого в коде три вида ссылок, и путать их нельзя. На страницу, которая уже на Next, ведёт <Link>: Next сам добавит basePath и префикс локали. На страницу, которая ещё на Webflow, ведёт обычный <a>, и префикс локали мы клеим сами. Остальное внешние ссылки.

export type Route =
  | { kind: 'internal'; path: string } // уже на Next: <Link>
  | { kind: 'webflow'; path: string }  // тот же домен, ещё Webflow: <a>
  | { kind: 'external'; href: string; newTab?: true };

// Для плоского <a>: в отличие от <Link>, он не клеит ни basePath, ни локаль
export function internalHref(path: string, locale: string): string {
  const prefix = locale === routing.defaultLocale ? '' : `/${locale}`;
  return `/app${prefix}${path === '/' ? '' : path}`;
}

export function webflowHref(path: string, locale: string): string {
  return locale === routing.defaultLocale ? path : `/${locale}${path}`;
}

Переезд страницы на прод в коде выглядит как смена kind: 'webflow' на 'internal' в одной строке. До этого новая страница живёт на Next под noindex, а оригинал всё ещё отвечает на Webflow. Потом снимаем noindex, добавляем страницу в sitemap, заливаем редиректы и переводим страницу Webflow в Draft.

Редиректы в Webflow

У 301 в Webflow несколько особенностей, которые я выяснил на практике.

Bulk import в настройках проекта работает на замену. Если залить CSV из десяти строк, остальные правила сайта пропадут. Поэтому в репозитории лежит только полный файл: экспортировали текущий список, дописали, залили обратно. Сейчас в нём 327 правил, рядом лежит исходная выгрузка на 80 правил как точка отката.

Локаль в Webflow часть пути, так что одна страница на пяти языках даёт пять правил. Wildcard-ов мы избегаем, цепочек 301 → 301 не допускаем. Webflow не даст повесить редирект поверх опубликованной страницы: её нужно перевести в Draft. Переносить такую страницу в папку /archive/ плохая идея, она попадёт в sitemap дублем.

Все страницы сайта собраны в один реестр, migration.registry.ts: у каждой статус (live, in-progress, planned) и старые адреса. На реестре держатся две проверки. Unit-тест сверяет его с роутами в src/app. Удалённый тест проходит по каждой live-странице и для каждой локали проверяет, что старый адрес отдаёт 301 на новый в той же локали, а новый отвечает 200. Удалённые тесты в CI нет, и это сделано намеренно: они проверяют состояние сайта, а не код. Их запускают руками до выкатки и после публикации в Webflow.

Что пришлось обойти на Webflow Cloud

Первая неприятность нашлась на реальных устройствах. Иконки в меню появлялись с задержкой, картинки дропдаунов дорисовывались на глазах, сильнее всего в iOS Safari. На localhost этого не видно. curl показал, что всё под /app приходит так:

cache-control: private, no-cache
cf-cache-status: BYPASS
set-cookie: _cfuvid=…

Платформа вешает куку _cfuvid на каждый ответ, а ответ с Set-Cookie кэши обязаны считать приватным. Это касается даже _next/static/* с хэшем в имени, которые Next помечает immutable. Переопределить заголовки из приложения нельзя: ни через headers() в next.config, ни файлом _headers.

Обходили на своей стороне. SVG-иконки и логотипы теперь лежат спрайтом прямо в DOM, иконка ссылается на <use href="#name"> и сеть ей не нужна. Картинки меню грузятся через <link rel="preload">. Растр в public/ только AVIF или WebP и не тяжелее 250 КБ. Это проверяет тест, иначе забытый PNG оплачивается трафиком на каждой загрузке: пять картинок меню после конвертации весили 78 КБ вместо мегабайта.

С картинками есть ещё нюанс: sharp на Workers не работает, поэтому next/image тут отдаёт файл как есть. В проекте нет ни одного next/image. Вместо него свой компонент Picture: нативный <picture> с <source media> для мобильной и десктопной картинки, а в AVIF их заранее конвертирует скрипт на sharp.

С кэшем страниц тоже не всё гладко. Сентябрьский аудит показал, что OpenNext без настроенного инкрементального кэша отдавал x-nextjs-cache: MISS на каждый запрос: TTFB 0,5–0,8 с, и 33 статических роута рендерились заново. R2-кэш в open-next.config.ts пока закомментирован, и насколько ISR в OpenNext на Workers готов к продакшну, я до конца не проверял.

После отключения Webflow фронт уедет на обычный Node-хостинг. Поэтому код пишем переносимым: в страницах нет fs и других Node-only API, всё платформенное спрятано за тонким адаптером. Отдельный документ перечисляет обходы, которые после переезда нужно откатить.

800 компонентов и дизайн-система

Компоненты собирали заново по дизайн-системе в Figma, экспорт HTML из Webflow не брали. Первые коммиты тянули из Webflow токены через DevLink, но их быстро заменили SCSS-переменными, написанными руками по Figma.

Код разложен на три слоя, и для каждого компонента есть правило, куда его класть. Если у него своя логика, состояние или данные, это features/ (квиз, поиск, запись на звонок, блог). Если он собран из двух и больше именованных компонентов в цельную секцию, это blocks/. Всё остальное идёт в ds/, это одиночные самодостаточные компоненты. Срабатывает первое «да» сверху вниз.

Сейчас в ds/ 114 компонентов, в blocks/ 89, подсистем в features/ 12. Вместо 800 с лишним компонентов Webflow вышло около двухсот. Большая часть разницы приходится на варианты: в Figma их 944 в 132 наборах, а в React это пропсы одного компонента.

Интерактивные компоненты стоят на headless-примитивах Ark UI: поведение, состояния и доступность берём у Ark, стили свои. Стили — SCSS и CSS Modules. Отступы, радиусы и цвета только из токенов, и это проверяет не ревьюер, а stylelint:

// Пропускаем 0 и хайрлайн 1px: это прижимы к пикселю, а не шкала
const RAW_LENGTH = /(?<![\w.$#])-?(?!(?:0|1)px\b)(?!0rem\b)\d*\.?\d+(?:px|rem)\b/;
// Прямой toRem(24) в обход шкалы — то же нарушение
const RAW_TOREM = /toRem\(\s*-?\d/;
const tokenRules = (props) =>
  Object.fromEntries(props.map((prop) => [prop, [RAW_LENGTH, RAW_TOREM]]));

export default {
  customSyntax: 'postcss-scss',
  rules: {
    'declaration-property-value-disallowed-list': {
      ...tokenRules(SPACING_PROPS),
      ...tokenRules(RADIUS_PROPS),
    },
    'color-no-hex': true,
  },
};

У каждого компонента есть сторис в Storybook 10, и сторис здесь тоже тест. Всего 255 файлов и 779 сторис. Каждая рендерится в браузере через @storybook/addon-vitest и прогоняется через axe с a11y.test: 'error', 574 из них ещё и проигрывают сценарий в play. Визуальные регрессии ловит Chromatic.

Правила React, которые держат страницы лёгкими

Главное правило: компонент серверный, пока не доказано обратное. Статичная секция без 'use client' не добавляет на клиент ни байта JS. 'use client' стоит в 135 файлах из 530. Где нужна интерактивность, клиентская оболочка принимает серверные children: так устроены шапка и анимации появления.

Переводы рендерятся на сервере через getTranslations, а клиентскому компоненту передаются только нужные ему строки. Аудит в сентябре нашёл нарушение: NextIntlClientProvider получал весь словарь, это около 145 КБ в RSC-payload каждой страницы.

Картинку под вьюпорт выбирает браузер через <picture>. User-Agent на сервере для этого не читаем. Вызов headers() делает роут динамическим, а HTML, который зависит от UA, ломает кэш на CDN. Серверный isMobile остаётся только для поведения, которое не выразить в CSS.

memo() в проекте не используется ни разу, а useMemo и useCallback стоят примерно в 60 местах. Когда большая часть дерева серверная, на клиенте почти нечему перерисовываться. Лишние рендеры ищем react-scan: npm run dev:scan подключает его только в dev. Анимации идут через LazyMotion с domAnimation и m.*, тяжёлые диалоги с видео и записью на звонок подгружаются через next/dynamic. Бандл смотрим @next/bundle-analyzer, и он собирает в отдельный distDir, чтобы не снести .next у запущенного dev-сервера.

Пару правил пришлось оформить в ESLint, потому что на них уже наступили. Тип ReactNode | ReactNode[] запрещён селектором в no-restricted-syntax: t.rich тоже отдаёт массив, и компонент, который делит массив на абзацы, однажды поставил <br> отдельным пустым <p> в первый экран страницы. Внутренний <a href="/…"> запрещён, потому что перезагружает страницу. Видеоплеер подключается только через одну обёртку: там у iframe правильный title, а куки выключены до согласия.

i18n на next-intl

Пять локалей, английская по умолчанию и без префикса:

export const routing = defineRouting({
  locales: ['en', 'ru', 'es', 'pt', 'fr'],
  defaultLocale: 'en',
  localePrefix: 'as-needed',
  // Без автоопределения по Accept-Language и cookie: /app всегда отдаёт en,
  // локаль переключается явно. Иначе русский браузер всегда улетал бы на /ru
  localeDetection: false,
});

В middleware есть тонкость с basePath: матчер считается относительно него, и '/' в матчере обязателен. Без него middleware не срабатывает на корне /app, и дефолтная локаль без префикса отдаёт 404.

Тексты статических страниц лежат в TS-объектах, по файлу на секцию: src/messages/<locale>/<section>.ts. Источник типов — английское дерево. Индекс остальных локалей устроен как { ...en, ...переведённые секции }: непереведённая секция показывает английский текст, а тест паритета ключей следит, чтобы переведённая не потеряла ни одного ключа.

Предложение с акцентом или ссылкой внутри хранится одной строкой с тегами и рендерится через t.rich. Делить предложение на несколько ключей нельзя, в разных языках разный порядок слов. Неразрывные пробелы руками не ставим: Typograf один раз проходит по всему дереву сообщений локали, и результат кэшируется на воркер. Это важно, потому что Figma MCP показывает неразрывный пробел как обычный. В русском макете одной страницы было 156 неразрывных пробелов, а до сообщений доехало 4. Typograf восстанавливает около 88% таких мест, остальное ставим вручную.

Страница, которой нет в какой-то локали, в этой локали отдаёт настоящий 404 через notFound(). hreflang и sitemap перечисляют только те локали, где страница есть. Редиректы, sitemap и hreflang должны совпадать.

Самое интересное в локализации связано с тем, откуда берётся текст. Правило в проекте такое: текст локали берётся только из макета этой локали в Figma и никогда не переводится с английского. Исключение одно: французский мы всегда переводим автоматически, макетов на нём нет. Для остальных языков автоперевод допустим только с явного разрешения, и это разрешение записано прямо в тесте. Почему так, видно в разделе про грабли.

Payload CMS

CMS — отдельное приложение: Payload 3 внутри Next.js, собранное в Docker (output: 'standalone'). Данные в Postgres, медиа в S3-совместимом хранилище. Схема меняется только миграциями (push: false), в контейнере они применяются при старте. Фронт читает CMS по REST.

Локализация в Payload включена на те же пять языков с fallback: true, но фронт всегда просит fallback-locale=none:

export function buildPostsUrl(collection: string, q: PostsQuery): string {
  const params = new URLSearchParams();
  params.set('locale', q.locale);
  // без этого Payload молча отдаёт en-текст там, где локали нет
  if (!q.fallback) params.set('fallback-locale', 'none');
  params.set('depth', String(q.depth ?? 1));
  // …limit, page, sort, where, select
  return `/api/${collection}?${params.toString()}`;
}

Иначе русская страница новости, которой на русском нет, спокойно покажет английский текст под русским URL.

Коллекции: новости, changelog, блог с таксономиями, упоминания в прессе, баннеры, вебинары, люди, медиа и пользователи. Тело поста — локализованный Lexical richText с блоками внутри текста:

// картинки в теле — только блоком media: у картинки один путь рендера
const EXCLUDED_FEATURES = new Set(['upload', 'relationship']);

export function postBody(blocks: Block[]): RichTextField {
  return {
    name: 'body',
    type: 'richText',
    required: true,
    localized: true,
    editor: lexicalEditor({
      features: ({ defaultFeatures }) => [
        ...defaultFeatures.filter((f) => !EXCLUDED_FEATURES.has(f.key) && f.key !== 'link'),
        urlLinkFeature(),
        BlocksFeature({ blocks }),
      ],
    }),
  };
}

Блоков семь: цитата, выделенный абзац, CTA-баннер внутри статьи, карточка человека, медиа (одна картинка или слайдер), видео и FAQ. Ссылки в тексте только по URL, без связей с коллекциями.

Одна мина Payload нашлась при проектировании схемы. Поле со списком доступных локалей нельзя назвать locales. Дочерняя таблица select-hasMany получила бы имя <collection>_locales, а это имя Payload резервирует под таблицу локализации коллекции, и билдер запросов на нём падает. Поэтому поле называется availableLocales.

Посты с черновиками и live preview на двух брейкпоинтах, 375 и 1440. Когда редактор публикует или снимает пост, afterChange дёргает ревалидацию на фронте:

export function shouldRevalidate(doc: PostDoc, previousDoc?: PostDoc): boolean {
  return doc._status === 'published' || previousDoc?._status === 'published';
}

export function revalidatePost(collection: string): CollectionAfterChangeHook {
  return async ({ doc, previousDoc }) => {
    if (shouldRevalidate(doc, previousDoc)) await callRevalidate(revalidateBody(collection, doc));
    return doc;
  };
}

Сохранение черновика ревалидацию не трогает. callRevalidate шлёт POST с секретом в заголовке на роут фронта, тот вызывает revalidatePath для каждой локали поста. Ошибка только логируется: публикация в CMS не должна падать из-за того, что фронт недоступен.

Поиск по новостям и блогу сделан на Postgres. Плагин @payloadcms/plugin-search собирает индекс, а миграция добавляет к нему сгенерированную колонку tsvector с весами (заголовок A, лид B, текст C), конфигурацией словаря под локаль и GIN-индексом. Если по запросу ничего не нашлось, эндпоинт пробует исправить опечатку по расстоянию Дамерау — Левенштейна против словаря раздела и возвращает corrected. Ещё одна тонкость: при синхронизации документ перечитывается с fallbackLocale: false, иначе английский текст попадает в русский индекс.

Как переносили контент из Webflow CMS

Небольшую коллекцию галерей хватило выгрузить в CSV одной кнопкой из админки Webflow, она уехала в статический контент. Для блога, новостей и changelog нужны были все пять локалей и записи из связанных коллекций-модулей. Их забирали через Webflow Data API v2 (/v2/collections/{id}/items/live с cmsLocaleId и пагинацией по 100), часть снимков сняли через MCP-сервер Webflow. Снимки лежат в JSON, импорт работает с ними, а не с живым API.

У Webflow есть особенность: он держит вариант записи в каждой локали, даже если её никто не переводил. Непереведённый вариант — это копия английского, причём часто устаревшая. Импорт считает локаль настоящей, только если вариант опубликован и его название отличается от английского:

export function availableLocales(variants: WebflowItem[]): WebflowLocale[] {
  const en = variants.find((v) => v.locale === 'en');
  return LOCALE_ORDER.filter((locale) => {
    const v = variants.find((x) => x.locale === locale);
    if (!v || !isLive(v)) return false;
    if (locale === 'en' || !en) return true;
    return v.fieldData.name !== en.fieldData.name;
  });
}

Rich text Webflow отдаёт HTML, Payload хранит Lexical JSON. Конвертация идёт в три шага.

Сначала HTML чистится в JSDOM. <figure> с картинкой или iframe превращается в абзац-маркер @@marker:N@@, а данные картинки или видео уходят в отдельный массив. В маркеры превращаются и плейсхолдеры вида {{_quote…}}, через которые в Webflow вставлялись цитаты, выделения и баннеры из отдельных коллекций. Заодно h6 становится h4, а пустые абзацы с одним неразрывным пробелом выбрасываются.

Потом очищенный HTML превращается в Lexical штатным convertHTMLToLexical из @payloadcms/richtext-lexical. Маркеры доходят до Lexical обычными абзацами, и третий шаг меняет их на блоки:

const MARKER = /^@@marker:(\d+)@@$/;

export function replaceMarkers(
  state: EditorState,
  resolve: (index: number) => LexNode | null,
): EditorState {
  const children = state.root.children.flatMap((node) => {
    const index = markerIndex(node); // абзац из одного текста по MARKER
    if (index === null) return [node];
    const block = resolve(index);
    return block ? [block] : [];
  });
  return { ...state, root: { ...state.root, children } };
}

Если после этого остался неразобранный плейсхолдер или маркер оказался внутри списка, импорт падает до записи в базу. Картинки скачиваются с CDN Webflow и загружаются в медиа Payload. Дубли отсекаются по ID файла Webflow, а у картинок из тела статьи, где ID нет в данных, его достают из 24-символьного hex-префикса имени файла. Записи обновляются по webflowId, поэтому импорт можно перезапускать. С DRY_RUN=1 он ничего не пишет и печатает отчёт в JSON.

Самые странные находки в данных:

  • кириллические буквы в ключах плейсхолдеров: их набирали в русской раскладке, и одна русская «а» молча ломала вставку блока;

  • кириллическая «С» в английских подписях;

  • поле месяца без года у части выпусков changelog;

  • тестовые записи test1, test-2 и empty-reserved;

  • русское слово в английском названии одного из форматов блога.

У каждой такой обработки в коде есть комментарий, почему она там. Встроенные посты из соцсетей и таблицы импорт не переносит молча, а выносит в отчёт для ручной проверки.

Проверки вместо ручной регрессии

На каждый PR в CI параллельно идут три job. verify гоняет stylelint, tsc --noEmit, unit-тесты (их больше 700) и ESLint. ESLint вешает аннотации прямо на строки диффа. secrets прогоняет gitleaks по коммитам PR. runtime-gates поднимает next dev и обходит все роуты из src/app и sitemap: axe, горизонтальный скролл на 375 px и пустые абзацы. Pre-commit гоняет пять быстрых тестов и gitleaks по staged-файлам. Storybook-тесты в CI не попали, на workerd они флейкают, поэтому идут локально и через Chromatic.

Правило в проекте простое: если договорённость может проверить машина, она переезжает из документации в гейт. Почти каждый гейт появился после конкретного случая.

Что случилось

Что ловит теперь

Русскую вкладку на странице тарифов собрали по английскому макету и перевели. «Создать инвойс» вместо «Создать счёт», 18 расхождений, дизайнер нашёл их через месяц

Тест происхождения текста: в каждом файле локали тег @locale-source с нодой Figma, адресом живой страницы или датой разрешённого автоперевода

На одной локали страница отдала 500: обычный t() на ключе, где только в русском был <br>

Тест: ключ с тегом хотя бы в одной локали можно читать только через t.rich

Страница ехала вбок на 481 px при ширине 375: 35 абсолютных .srOnly в скролл-контейнере без position: relative

Runtime-гейт меряет горизонтальный скролл и называет виновника: span.ComparisonTable.srOnly ×21 x=856

Агент взял ширину формы 496 из архивной ноды Figma вместо 544, и формы разъехались на всех страницах

Тест: ширина карточки формы задаётся только в трёх модулях

Редиректы завели только на английский, 8 адресов отдавали 404

Удалённый тест редиректов по всем локалям

В русском меню Webflow ссылка вела на английскую страницу Next

Удалённый тест локали ссылок в меню

В public/ лежало 3,6 МБ неоптимизированных PNG

Тест формата и веса растра

Экспорт из Figma MCP запекал серую подложку #959595, на канвасе её не видно

Детектор подложки в скрипте экспорта и тест на него

В <title> бренд повторялся дважды

Тест суффикса в метатегах

Отдельная история с Lighthouse. Пресет unlighthouse меряет без замедления CPU (cpuSlowdownMultiplier: 1), а мобильный PageSpeed замедляет в четыре раза. На странице тарифов это дало 87 баллов вместо 61. Параллельные прогоны давали ещё и 52. Теперь замер идёт с замедлением в 4 раза и строго по одному. Про производительность будет отдельная статья. Пока одна цифра: из 660 мс Total Blocking Time нашему коду принадлежат 34, остальное съедают сторонние скрипты аналитики, чата и пикселей.

Где тут AI-агент

Код я пишу вместе с Claude Code, и в репозитории под это заведена инфраструктура. В CLAUDE.md лежит короткая карта проекта, а правила вынесены в .claude/rules/ и подгружаются по путям: стили только при работе с *.scss, правило «страница отдельно от контента» только для page.tsx. Под повторяющиеся задачи написано десять skills. Обязательный из них — чеклист перед любой страницей или блоком. В нём один блокирующий пункт: если нет макета локали, агент останавливается и спрашивает.

Есть скилл выгрузки картинок из Figma с правилом «выгружаем контейнер, а не рисунок». Есть скилл метатегов: title и description он пишет по стоп-листу и глоссарию, а текущую мету читает в последнюю очередь, чтобы не подстраиваться под неё. Ещё есть скиллы разбора комментариев дизайнера из Figma, проверки доступности, еженедельного аудита и статуса переезда.

Страница собирается так. Геометрию макета даёт Figma MCP, а тексты с неразрывными пробелами и скрытые слои приходят через REST. Потом пишется код, прогоняются npm run verify и runtime-гейт. После этого идёт сверка с макетом числами: Playwright снимает getBoundingClientRect на 1440 и 375, отдельный скрипт проверяет переносы строк. Совпадение с макетом до пикселя гейты не проверяют, это делают замер, Storybook и Chromatic.

Агент работает быстро и ошибается правдоподобно. «Создать инвойс» выглядит таким же нормальным текстом, как «Создать счёт», а ширина 496 такой же нормальной цифрой, как 544. По-моему, главный навык в такой разработке — заранее знать, где агент ошибётся, и поставить там проверку. Поэтому в проекте есть ещё и мод для Claude Code. Он не даёт агенту запустить полный runtime-аудит без списка роутов (однажды это повесило мой Mac), собрать next build рядом с работающим next dev (общая папка .next, dev начинает отдавать 500) и убить Storybook.

Что получилось

Сколько занимают задачи до и после переезда

Сколько занимают задачи до и после переезда

Новая страница в Webflow занимала 2–3 дня, теперь в среднем 3–6 часов. Перевести сайт на новый язык в Webflow стоило двух месяцев ручной работы. Теперь это строка в routing, локаль в Payload и файлы сообщений, которые до перевода показывают английский. По моей оценке, на весь сайт уйдёт около дня, и узким местом остаётся сам текст. Регрессию после изменений проверяет машина.

Webflow стоил $526 в месяц. После его отключения инфраструктура, по нашему расчёту, будет стоить $20–40 в месяц при том же трафике.

Реестр страниц появился только в августе, через полтора месяца после старта. До него счётчик прогресса считали руками, и он долго показывал 1 из 150 при трёх живых страницах. Заводить реестр стоит в первый же день, вместе с удалённым тестом редиректов.

Если вы тоже переезжали с Webflow или другого конструктора с локалями в пути, расскажите в комментариях, как вы решали редиректы. Особенно интересно, как вы разбирались с непереведёнными вариантами записей в CMS.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.