Мир CMS. Как я интегрировала Headless CMS в свой блог

Пост создан 2024-01-04

Если ищете CMS, чтобы начать писать контент, вы проиграли.

Искать СMS надо, когда какой-то контент уже написан.

Стэтхем

Я всегда говорю, что ты либо разрабатываешь блог, либо пишешь посты в него. Баланс найти сложно.

Поэтому год назад я зашла на сайт jamstack.org, нажала «Deploy on Netlify», и начала вести блог с простых .mdx (маркдаун + react) файлов.

Спустя год, когда я добралась до создания раздела заметок, я поняла, что количество статических файлов разрастается. Заметки можно постить каждый день. Получается, если несколько лет вести такой блог, то количество файлов перевалит за десятки, а то и сотни.

Пора бы уже организовать отдельное хранилище для статей в базе данных.

К тому же, я ненавижу верстать картинки. Лучше уж подогнать их в визуальном редакторе по размеру и расположению.

Так я дошла до идеи, что мне нужна какая-то БДшка и какая-то АПИшка. Далее расскажу, какие редакторы для постов я пробовала, как там организовывается структура и хранение данных, и почему у меня ко всем есть претензии.

Начало: Strapi

Я сразу же вбросила в гугл вопрос: а какие CMS (aka Content Manager System) сейчас ставят? Пару лет назад я поднимала WordPress чисто ради API и продвинутого редактора, но кажется, можно попробовать что-то еще.

На мои глаза попался Strapi. Я когда-то писала здесь про рабочий хакатон:

В первые пару часов быстро стало понятно,

что strapi – не то, что нужно для быстрого MVP.

пост

Меня это не остановило. Раз хвалят, будем пробовать.

Getting Started

Выполняем команду из доки:

shell
    yarn create strapi-app my-project --quickstart

Консоль сообщает, что все готово к использованию. По инструкции мне удается создать тип Note (моя заметка) с разными полями, связать его с типом Tag (теги по которым я фильтрую контент).

Все поля я вручную накликала мышкой.

Strapi умеет выдавать данные через:

  • GraphQL;
  • REST API;
  • Entity Service API;
  • Query Engine API.

Ничего не могу сказать про последние два пункта, но для быстрого прототипирования будем использовать простой советский REST.

Подключаем к блогу

У меня статический блог. Мне важно получать slug (человеко-понятный уникальный идентификатор, часто используется для урлов) для формирования страничек, контент в Markdown (потому что у меня mdx), а также иметь систему тегов.

Вот такой ответ я получила с локального сервера:

localhost:1337/api/notes.json

{
   "data":[
      {
         "id":1,
         "attributes":{
            "title": 'Моя первая статья',
            "content":"...",
            "published":false,
            "createdAt":"2023-12-18T10:25:35.293Z",
            "updatedAt":"2023-12-18T13:57:20.059Z",
            "publishedAt":"2023-12-18T13:57:20.058Z",
            "slug":"02-article",
            "oldDate":"2023-12-16",
         }
      }
   ]
}

Для RESTа нужно напрямую указывать, какими связями обогащать ответ. Так как теги – отдельная сущность, они не попали в «голый» запрос. Таким образом мы можем контролировать объем данных, который запрашивает каждая страница.

Урлы в итоге могут выглядеть как-то так:

shell
    GET /notes?populate[0]=tags

А еще можно прописать, какие конкретно поля стоит включать в ответ.

shell
    GET /notes?populate[0]=tags&fields[0]=title

C этим разобрались. С populate и fields мне поможет либа qs.

js
const qs = require('qs');
const query = qs.stringify(
  {
    fields: ['title'],
  },
  {
    populate: ['tags']
  }
);

await request(`/api/notes?${query}`);

Подключаю блог к strapi, итоговый код интеграции такой:

js
    ...
    const params = qs.stringify({
      populate: ['tags'],
      sort: ['publishedAt:desc']
    });
    const url = new URL('http://localhost:1337/api/notes?' + params);

    const itemsData = await fetch(url)
      .then(res => res.json())

    const items = itemsData
      .data
      .map(buildItem); // небольшой маппинг данных
     ...

Strapi Cloud

Я потратила на локальную разработку и тестирование примерно пару вечеров.

Только потом я решила открыть вкладку Strapi Cloud.

$99 в месяц?! Нет, мы пойдем бесплатным self-host путем.

Селф-хостинг

Strapi жирный, и большинство статей советуют сервера с двумя ядрами. То есть, рядом ничего не поставишь, а два ядра будут стоить на несколько баксов дороже. Такой режим установки назову «standalone».

Во-вторых, в команде:

shell
    yarn create strapi-app my-project --quickstart

параметр --quickstart cоздает инстанс Strapi на базе SQLite, а мне бы хотелось Postgres. Небольшой промах, из-за которого пришлось вбивать данные заново.

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

Пока я думала, нужно ли мне все это, я решила посмотреть, какие еще есть Headless CMS.

Sanity

More like IN-sanity, am I right??

Пользователь реддита Potential-Still

Sanity.io – самая дешевая Headless CMS которую я нашла. Базовый тариф бесплатный, при этом они хостят базу данных у себя в облаке.

За данными можно ходить с помощью GROQ или GraphQL. А теперь самое интересное: что с фронтенд частью CMS?

Все просто: достаточно держать ее запущенной локально. Я пробовала встраивать ее прямо внутри Next.js блога в папке studio. Назову это «embedded»-режимом. Все данные находятся в облаке Sanity, которое они называют Content Lake, и оно доступно отовсюду.

Content Lake менеджерится самой CMS, и туда не так просто встроить свои данные. Такова цена бесплатной подписки.

Как разрабатывать?

Про разработку

Я положила простой конфиг в <my-blog>/studio/sanity.config.ts:

sanity.config.ts
import {defineConfig} from 'sanity'
import {deskTool} from 'sanity/desk'
import {visionTool} from '@sanity/vision'
import {schemaTypes} from './schemas'
import {markdownSchema} from 'sanity-plugin-markdown'

export default defineConfig({
  name: 'default',
  title: process.env.SANITY_STUDIO_NAME,

  projectId: process.env.SANITY_STUDIO_PROJECT_ID || '',
  dataset: process.env.SANITY_STUDIO_DATASET || '',

  plugins: [deskTool(), visionTool(), markdownSchema()],

  schema: {
    types: schemaTypes,
  },
})

deskTool() – интерфейс, в котором можно создавать контент; visionTool() – интерфейс, в котором можно тестировать GROQ-запросы; markdownSchema() – дополнительный пакет, который разрешает указывать тип markdown у полей.

projectId и dataset берутся из аккаунта. Так CMS связывается с облачной БД, что позволяет пользоваться фронтом локально.

Особенности: запросы

Sanity предлагает делать все с помощью GROQ. Синтаксис GROQ-запросов на первый взгляд пугающий. Вот как я запрашиваю заметки с определенным тегом:

sh
 *[_type == "note" && "${tag}" in tags[]-> slug.current]{
    _id,
    title,
    description,
    "tags": tags[]->  title,
    bridgyEndpoints,
    reply,
    syndicated,
    content,
    oldDate,
    slug,
    _createdAt,
    _updatedAt,
    _type,
};

Но в целом, привыкаешь. Также есть альтернатива в виде GraphQL.

Embedded-установка

Использование Headless CMS внутри репозитория экономит деньги на хостинге.

Но при такой настройке зависимости лучше держать зафиксированными. Когда я поставила пакет «sanity-plugin-markdown», он ни в какую не хотел заводиться, пока я не убрала крыжики с версий Sanity и этого плагина.

Поддержка у команды в слаке. И она немного мертвая.

Схемы данных

Допустим, хотим сделать тип данных «Тег». Пускай у него будут поля «title», «slug», и «description».

js

export const tag = {
  name: 'tags',
  title: 'Tags',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      title: 'Title',
      type: 'string',
    }),
    defineField({
      name: 'slug',
      title: 'Slug',
      type: 'slug',
      options: {
        source: 'title',
        maxLength: 96,
      },
    }),
    defineField({
      name: 'meta_description',
      title: 'Meta Description',
      type: 'text',
    }),
  ],
}

Я хочу, чтобы тегом можно было отметить «Заметки» и «Посты». Причем, со связью «many-to-many». Для этого я укажу в схемах для типов Posts и Notes референс.

js
...
  defineField({
    name: 'tags',
    title: 'Tags',
    type: 'array',
    of: [{type: 'reference', to: {type: 'tags'}}],
  }),
...

Моя админка выглядит так:

Миграции

А что будет, если я заполню все это данными, а потом решу, что я хочу заполнять description как маркдаун-поле?

Ничего страшного, просто выполните вот такой код:

js
import { getCliClient } from 'sanity/cli'

const client = getCliClient()

const fetchDocuments = () =>
  client.fetch(`*[_type == 'author' && defined(name)][0...100] {_id, _rev, name}`)

const buildPatches = docs =>
  docs.map(doc => ({
    id: doc._id,
    patch: {
      set: {fullname: doc.name},
      unset: ['name'],
      // this will cause the transaction to fail if the documents has been
      // modified since it was fetched.
      ifRevisionID: doc._rev
    }
  }))

const createTransaction = patches =>
  patches.reduce((tx, patch) => tx.patch(patch.id, patch.patch), client.transaction())

const commitTransaction = tx => tx.commit()

const migrateNextBatch = async () => {
  const documents = await fetchDocuments()
  const patches = buildPatches(documents)
  if (patches.length === 0) {
    console.log('No more documents to migrate!')
    return null
  }
  console.log(
    `Migrating batch:\n %s`,
    patches.map(patch => `${patch.id} => ${JSON.stringify(patch.patch)}`).join('\n')
  )
  const transaction = createTransaction(patches)
  await commitTransaction(transaction)
  return migrateNextBatch()
}

migrateNextBatch().catch(err => {
  console.error(err)
  process.exit(1)
})

БУ! ...или просто экспортировать все данные и отредактировать их руками. Проще не ошибаться.

...Дальше?

Все вроде бы неплохо, и я даже опубликовала несколько заметок через Sanity, перекопировав их из существующих файлов. Однако, когда я села вбивать заметку руками через интерфейс, окно редактирования несколько раз крашнулось с перезагрузкой, из-за чего я потеряла часть набранного текста. Неприятно, учитывая, что в зависимостях у меня были только такие модули, которые Sanity сам разрабатывает.

Поэтому я решила двигаться дальше и попробовать набирающую популярность Payload CMS.

Payload CMS

И вот я добралась до продукта, основатели которого гордятся, что он code-first. То есть, это практически ORM (прослойка между базой данных и кодом), да еще и с интерфейсом. Все в этой CMS делается с помощью кода, кроме добавления сущностей.

Тип контента

Payload CMS предлагает несколько редакторов текста, SlateJS и Lexical. Последний – от фейсбука, и разработчики CMS написали сверху обвязку, которая делает его похожим на редактор статей Medium. Формат сохраненных данных – Rich Text, с которым можно много чего сделать. Например, создатели утверждают, что можно легко конвертировать текст из Lexical в маркдаун. Но из доки так и не ясно, какие шаги предпринимать.

Так описываются схемы:

js
const Notes: CollectionConfig = {
  slug: 'notes',
  fields: [
    {
      name: 'title', // required
      type: 'text', // required
      required: true,
    },
    {
      name: 'content',
      type: 'richText',
      label: 'Lexical Rich Text Editor', // в настройках проекта/типа указывается editor: lexical
    },
  ]
}

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

Хранение данных

Payload CMS умеет работать с MongoDB и Postgres. В 2024 году обещают завезти MySQLite и MySQL. Удобно, потому что, в отличие от Sanity, ты не привязываешься к черному ящику Content Lake, а хранишь данные в доступной тебе БД по описанным схемам.

Миграции

Создатели утверждают, что мигрировать данные можно с помощью двух функций.

js
export async function up({ payload }: MigrateUpArgs): Promise<void> {
  // Perform changes to your database here.
  // You have access to `payload` as an argument, and
  // everything is done in TypeScript.
};

export async function down({ payload }: MigrateDownArgs): Promise<void> {
  // Do whatever you need to revert changes if the `up` function fails
};

Не проверяла на себе, доверимся документации.

Запросы

В Payload CMS есть три типа запросов:

  • REST;
  • GraphQL;
  • Local API.

REST

Первый тип запросов генерируется на эндпоинте api/<slug-name>/..., работает на qs, аналогично со Strapi.

GraphQL

Честно говоря, так и не поняла как подтянуть его, когда CMS собирается на отдельном сервере как отдельное приложение. Оставлю пометку, что шанс такой есть.

Local API

В Sanity я встраивала CMS прямо в свой блог (embedded-режим). Таким образом, в перспективе, я могла переиспользовать типы данных из схем CMS и также докидывать в отображение свои кастомные компоненты.

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

js
payload.find({
  collection: 'notes'
})

При этом в проекте должен быть payload.init(), и он обязательно должен инициализироваться выше этой функции.

Хостить отдельно или встраивать?

Embedded-режим

Встраивание CMS звучит как идеальная комбинация: можно создавать Live-редактирование без дублирования кастомных компонентов, можно использовать Local API, который ходит напрямую в БД и экономит время при статическом билде (киллер фича с Next.js).

Однако Payload CMS работает с express.js, и для корректной работы ему нужно прокидывать инстанс экспресса при ините, чтобы он создал там свои роуты.

Next, с другой стороны, тоже использует express. Но он зарыт где-то в глубине приложения, и прямого доступа к нему нет. Пока что я экспериментирую с различными вариантами, чтобы понять, как заставить комбинацию работать.

Документация вроде бы дает все, что нужно, но официальный демо-пример не работает с Nextjs@14 и создатели Payload CMS так и не смогли разобраться, почему. Об этом они пишут в ридми next-payload.

Также команда объявила, что в следующем году они будут переезжать на Next.js в новом мажоре. Я отношусь ко многим идеям NextJS скептически (да, при том, что мой блог сделан на NextJS и я его регулярно обновляю), поэтому меня немного настораживает «монополизация» рынка.

Standalone-режим

Конечно, возникают проблемы с дублированием компонентов, если хочется лайв режима, а также, при изменении схемы в БД придется деплоить оба приложения. При статическом билде сайта такой спешки нет. Если, конечно, нет активных вебхуков, что в моем случае уже проблема: я работаю над приложением, которое будет дергать нетлифай на пересборку.

Итог

Min Cloud PriceSelf-HostDatabasesTypeScriptSchemaМиграцииРежимЗапросы
Strapi$99+SQLite, MySQL, Postgres+Ui🧐 +StandaloneREST, GraphQL, Entity?, Query???
Sanity.io$0+Content-Lake (своя)+Code🤢 +Embedded, StandaloneGROQ, GraphQL
Payload CMS$35+Postgres, MongoDB+++!Code🙂 +Embedded, StandaloneREST, GraphQL, LocalAPI

Теперь посмотрим, почему можно выбрать любую из этих CMS.

Почему Strapi

Схемы можно натыкать мышкой. Разберется и программист, и обычный работник. Можно развернуть без проблем в любом контейнере. Полностью open source; Довольно популярная.

Минусы:

  • мышкой тыкать долго;
  • утомительная документация;

Почему Sanity

Дешево! Много кастомизации. Легко развернуть – буквально 10 минут и можно идти накидывать схемы, которые даже не нужно куда-то физически деплоить. Все хранится в Content Lake.

Минусы:

  • в Content Lake так просто не залезешь, только если мигрировать данные программно. Миграции вы видели;
  • вялая поддержка в слаке;
  • устаревшая документация.

Почему Payload CMS

Минималистичный дизайн и Lexical Editor, а также в активное коммьюнити вокруг CMS.

Минусы:

  • документация часто заводит в тупики и приходится копаться в примерах и исходном коде;
  • некоторые фичи плохо работают на свежих версиях Next.js;
  • привязан к express.

На момент написания статьи я решила пока остановиться на Payload CMS. Интересно посмотреть, куда будет развиваться команда.

Напоследок: А почему не?

Ну и если кто-то спросит, а почему не?..., то я отвечу, что перетыкала я намного больше решений, чем описала здесь. В основном я ориентировалась на топ часто упоминаемых контент-менеджеров с Jamstack'а и реддита.

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

Есть и другие интересные CMS:

Tina CMS – Git-based CMS.

Тина сохраняет данные как .md файлы. Среди клиентов – Smashing Magazine, вот статья о том, как они строят процесс публикации статей.

фичи:

  • лайв эдит статей;
  • GraphQL;
  • понятный интерфейс.

минусы для меня:

  • нужны физические коммиты в репозиторий.

KeystoneJS CMS – API-driven CMS с Prisma (ORM) в коробке.

фичи:

  • редактор SlateJS;
  • GraphQL;

минусы для меня:

  • некоторые Issue месяцами висят без внимания и не чинятся.

Итог

Когда я садилась писать этот пост две недели назад, я планировала остановиться на Sanity. В процессе передумала, и начала переписывать все на Payload CMS.

Несмотря на то, что на данный момент я сделала выбор, я пока я не нашла идеальный редактор 🥲 может быть, придется писать свой.

Я прошла длинный путь от «хочу установку в один клик» до «я готова возиться с БД, хостить фронт CMS и S3 сервер, только дайти мне то, что я хочу». Хочу я не так много: хороший редактор с возможностью загружать картинки и редактировать их в режиме реального времени.

Буду надеяться, что рано или поздно я смогу допинать Payload CMS до картины своей мечты.