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

Привет! Я Серёжа Шилов, фаундер IT-аутсорс компании We Wizards. Моя команда занимается web&mobile разработкой для e-comm, лучше всего делаем e-commerce решения от складов до аналитики.

Что мы вообще делали?
Старый сайт на руби, который устарел в плане дизайна и страдал от низкой скорости загрузки, что негативно влияло на пользовательский опыт и трафик.
Поэтому задачей стала разработка нового сайта с современным, привлекательным дизайном. При этом критически важно было сохранить и перенести все статьи и изображения со старого ресурса, минимизируя риск потери трафика и просмотров в поисковых системах.
Нам поручили именно разработку сайта.
Мы выбрали WordPress — во-первых, потому что это исторически блоговая платформа, а во-вторых, потому что там используется один из лучших в мире визуальных редакторов конте��та — Gutenberg.
В рамках проекта нам нужно было сделать платформу для блогов
Задача заключалась в том, чтобы дать контент-менеджерам возможность работать с редактором Gutenberg от WordPress, но в современном формате — через REST API.
Gutenberg — это современный визуальный редактор контента от WordPress. Он сам по себе написан на React, и блоки для него можно создавать либо с помощью React UI (специальной библиотеки от WordPress), либо на синтаксисе JSX. Однако такой подход предполагает монолитное решение — то есть фронтенд пришлось бы разрабатывать внутри WordPress. А мы хотели разделить API и клиентскую часть.
Поэтому выбрали следующий стек: WordPress в headless-режиме с Gutenberg и ACF — в роли CMS и API, а фронтенд — на Vue 3 с Nuxt. Ну а дальше — как пойдёт :)

Почему всё оказалось не так просто?
Сначала мы наткнулись на один неприятный момент: ACF (поля для блоков в WordPress) по умолчанию возвращает данные в API в плоском, неструктурированном виде. Причём одинаковые поля могут приходить в разных форматах, что добавляет путаницы.
В монолитной архитектуре Gutenberg строит всё из блоков — внутри блоков могут быть другие блоки и поля, но мы ожидали увидеть просто массив блоков с полями в них. А на деле получаем плоский JSON, и приходится разбираться с этим.
Чтобы это исправить, мы написали собственные фильтры и адаптер, который на бэке приводил все данные к нормальному формату.
Да, пришлось даже з��лезть в код ACF и разобраться, как он вообще формирует структуру. Но теперь у нас есть JSON, понятный фронту.
{ acf_fields: { title: "Онлайн курсы <br />и живые мероприятия", desc: "На сегодняшний день мы затрагиваем самые различные направления, которые позволят Вам совершенствоваться в каждом аспекте, познавать окружающий мир и себя в этом пространстве. <br />Практикуя и работая над собой, вы сможете быстро и легко перенестись на новый качественный уровень жизни.<br />", menu: [ { title: "Финансы", link: "#" }, { title: "Питание", link: "#" }, { title: "Отношения", link: "#" }, { title: "Энергия", link: "#" } ], show_menu_bg: true, bg_img: false, bottom_big_space: true, } }
Как выглядел фронт
На фронте мы выстроили такую архитектуру:
Все блоки приходят массивом в JSON.
У каждого блока есть slug — его уникальный идентификатор.
Компоненты блоков лежат в отдельной папке и подключаются по этому слагу.
Всё это динамически рендерится на странице через v-for и функцию findBlock().
Позже мы немного упростили жизнь и добавили автоимпорт компонентов из папки — это избавило от ручного импорта и сократило количество кода.
const componentMap: Record<string, Component> = {}; Object.entries(import.meta.glob('~/components/block/*.vue')).forEach(([path, importFn]) => { componentMap[path.split('/').pop()?.replace('.vue', '') || ''] = defineAsyncComponent( importFn as () => Promise<Component>, ); }); export const getComponent = (name: string): Component => { const founded = componentMap[name]; if (!founded) console.warn(`Block "${name}" not found`); return founded; };
Данные приходили как попало, без валидации
Одно и то же поле могло быть строкой, объектом или вообще массивом. А так как на стороне WordPress контент-менеджер может в любой момент что-то поменять, не заполнить поле или внезапно переименовать его, всё могло пойти по наклонной.
На старте мы просто прокидывали огромные props и всё оборачивали в optional chaining, чтобы хотя бы не падало. Но это была временная мера — чтобы просто работало.
const props = defineProps<{ data: { acf_fields: { s_image_settings?: string; s_image_txt_settings?: string; s_icon: boolean; image_with_button_block?: { old_url_img: string | OldImg; img: IImage; btn_url?: string; btn_txt?: string; desc: string; s_txt?: string; }; image_with_txt?: { include_stars?: boolean; txt_1?: string; txt_2?: string; desc?: string; old_url_img: string | OldImg; img?: IImage; }; half_image_with_button_block?: { img_1: { liked: boolean; old_url_img: string | OldImg; id: number; url: string; width: number; height: number; alt: string; }; img_2: { liked: boolean; old_url_img: string | OldImg; id: number; url: string; width: number; height: number; alt: string; }; desc_1: string; desc_2: string; btn_txt_1: string; btn_txt_2: string; btn_url: string; btn_url_2: string; }; }; }; }>();
Как мы сделали "редактор в редакторе"
Gutenberg — штука красивая. И в идеале она должена показывать превью контента в реалтайме — то есть пока редактируешь данные, сразу видишь, как будет выглядеть итог. Но в headless-режиме всё рушится: фронта внутри WordPress нет, это два разных мира, два инстанса.
Можно было бы пойти по другому пути — сделать ещё один фронт прямо внутри WP. Но, камон, это дичь.
Мы решили так:
Бэкенд генерит временный токен;
По нему можно открыть превью на фронте;
Открывается страница на сайте закрытая под хэш ключик
const { private_token, pid } = route.query; const pageKey = `article-${slug}`; const updatePostContent = async (): Promise<void> => { if (useUserStore().user.info.club_member && import.meta.client) { const reqData: { slug: string; private_token?: string; pid?: string; } = { slug: slug, }; if (private_token && pid) { reqData.private_token = String(private_token); reqData.pid = String(pid); } const res = await api.blogController.getPost(reqData as PostDataRequest); postData.value = { data: res.data } as PostDataResponse; } };
Да, это не идеально. Но работает.
Пишите в комментах, если у вас есть идеи, как сделать лучше.
SSR, SSG и боль с модалками
По дизайну все статьи (Кор механика сайта) открываются в модальном виде, отдельной страницы статьи по урлу не открыть, поэтому…
Мы хотели использовать SSG (статическую генерацию) для скорости. Но:
контент часто меняется (думали чтобы Wordpress триггерил по API сервер с фронтом, но NUXT Не умеет генерить отдельные страницы по ID или SLUG);
Cloudflare не всегда видит эти изменения (первое время мы вручную управляли CF, сейчас там уже апишка после деплоя обнлять кеш, но все равно см.п.1);
при открытии статьи в модалке — слетает гидрация, ну эт потому что NUXT. Кстати, пишите в комменты если вы знаете решение
В итоге остались на SSR. Зато теперь всё работает стабильно. Ну, почти всегда :)
Мини-Sentry на коленке и мониторинг статуса фронта
Настоящий Sentry — круто, но не всегда вписывается в бюджет MVP. Мы сделали свой мини-логгер:
Ошибки на фронте ловятся плагином и отправляются в API;
Бэк сохраняет их в лог и отправляет в Telegram-бота.
Так мы узнали, какие страницы роняют сайт. Написали автотест, который просто проходится по всем статьям и проверяет статус-коды.

Финал
Сайт получился сложным, но чертовски интересным. Мы не избежали костылей. Мы спорили, переделывали, рефакторили. Но при этом выстроили архитектуру, которую теперь хотим развивать и использовать в других проектах.
Что мы сейчас имеем? Почти все преимущества Gutenberg для редакторов контента, классный бекенд с редисами и прочими плюхами, классный фронт.
Ранее я не видел в интернете хорошего коммерческого решения решения связки Guteberg + Vue/Nuxt, а теперь оно есть!
