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

Привет! Я Серёжа Шилов, фаундер 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, а теперь оно есть! 

Комментарии (1)


  1. its2easyy
    30.05.2025 13:50

    А вы использовали только гутенберг блоки созданные через acf (где, как я понимаю, acf поля блока идут в пропсы vue компонентов) или как то смогли обрабатывать нативные блоки и блоки от плагинов?

    Открывается страница на сайте закрытая под хэш ключик

    Для этого же вроде готовые решения есть. Или вы сделали так, что в превью показываются несохраненные изменения и обновляются во время правок?