Мир CMS. Как я интегрировала Headless CMS в свой блог
Если ищете CMS, чтобы начать писать контент, вы проиграли.
Искать СMS надо, когда какой-то контент уже написан.
– Стэтхем
Я всегда говорю, что ты либо разрабатываешь блог, либо пишешь посты в него. Баланс найти сложно.
Поэтому год назад я зашла на сайт jamstack.org,
нажала «Deploy on Netlify», и начала вести блог с простых .mdx
(маркдаун + react) файлов.
Спустя год, когда я добралась до создания раздела заметок, я поняла, что количество статических файлов разрастается. Заметки можно постить каждый день. Получается, если несколько лет вести такой блог, то количество файлов перевалит за десятки, а то и сотни.
Пора бы уже организовать отдельное хранилище для статей в базе данных.
К тому же, я ненавижу верстать картинки. Лучше уж подогнать их в визуальном редакторе по размеру и расположению.
Так я дошла до идеи, что мне нужна какая-то БДшка и какая-то АПИшка. Далее расскажу, какие редакторы для постов я пробовала, как там организовывается структура и хранение данных, и почему у меня ко всем есть претензии.
Начало: Strapi
Я сразу же вбросила в гугл вопрос: а какие CMS (aka Content Manager System) сейчас ставят? Пару лет назад я поднимала WordPress чисто ради API и продвинутого редактора, но кажется, можно попробовать что-то еще.
На мои глаза попался Strapi. Я когда-то писала здесь про рабочий хакатон:
В первые пару часов быстро стало понятно,
что strapi – не то, что нужно для быстрого MVP.
– пост
Меня это не остановило. Раз хвалят, будем пробовать.
Getting Started
Выполняем команду из доки:
yarn create strapi-app my-project --quickstart
Консоль сообщает, что все готово к использованию. По инструкции мне удается создать
тип Note
(моя заметка) с разными полями, связать его с типом Tag
(теги по которым я фильтрую контент).
Все поля я вручную накликала мышкой.
![Внешний вид Strapi](/_next/image?url=%2Fthe-world-of-cms%2Fimg.jpg&w=1920&q=75)
Strapi умеет выдавать данные через:
- GraphQL;
- REST API;
- Entity Service API;
- Query Engine API.
Ничего не могу сказать про последние два пункта, но для быстрого прототипирования будем использовать простой советский REST.
Подключаем к блогу
У меня статический блог. Мне важно получать slug
(человеко-понятный уникальный идентификатор, часто используется для урлов)
для формирования страничек, контент в Markdown
(потому что у меня mdx), а также иметь систему тегов.
Вот такой ответ я получила с локального сервера:
{"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а нужно напрямую указывать, какими связями обогащать ответ. Так как теги – отдельная сущность, они не попали в «голый» запрос. Таким образом мы можем контролировать объем данных, который запрашивает каждая страница.
Урлы в итоге могут выглядеть как-то так:
GET /notes?populate[0]=tags
А еще можно прописать, какие конкретно поля стоит включать в ответ.
GET /notes?populate[0]=tags&fields[0]=title
C этим разобрались. С populate
и fields
мне поможет либа qs
.
const qs = require('qs');
const query = qs.stringify(
{: ['title'],
fields
},
{: ['tags']
populate
}
);
await request(`/api/notes?${query}`);
Подключаю блог к strapi, итоговый код интеграции такой:
...
const params = qs.stringify({
: ['tags'],
populate: ['publishedAt:desc']
sort
});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.
![](/_next/image?url=%2Fthe-world-of-cms%2Fimg_2.jpg&w=3840&q=75)
$99 в месяц?! Нет, мы пойдем бесплатным self-host путем.
Селф-хостинг
Strapi жирный, и большинство статей советуют сервера с двумя ядрами. То есть, рядом ничего не поставишь, а два ядра будут стоить на несколько баксов дороже. Такой режим установки назову «standalone».
Во-вторых, в команде:
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
:
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({
'default',
name: process.env.SANITY_STUDIO_NAME,
title:
process.env.SANITY_STUDIO_PROJECT_ID || '',
projectId: process.env.SANITY_STUDIO_DATASET || '',
dataset:
deskTool(), visionTool(), markdownSchema()],
plugins: [
schema: {schemaTypes,
types:
},
})
deskTool()
– интерфейс, в котором можно создавать контент;
visionTool()
– интерфейс, в котором можно тестировать GROQ-запросы;
markdownSchema()
– дополнительный пакет, который разрешает указывать тип markdown у полей.
projectId
и dataset
берутся из аккаунта. Так CMS связывается с облачной БД, что позволяет пользоваться фронтом локально.
Особенности: запросы
Sanity предлагает делать все с помощью GROQ. Синтаксис GROQ-запросов на первый взгляд пугающий. Вот как я запрашиваю заметки с определенным тегом:
*[_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».
export const tag = {
: 'tags',
name: 'Tags',
title: 'document',
type: [
fieldsdefineField({
: 'title',
name: 'Title',
title: 'string',
type
}),defineField({
: 'slug',
name: 'Slug',
title: 'slug',
type: {
options: 'title',
source: 96,
maxLength
},
}),defineField({
: 'meta_description',
name: 'Meta Description',
title: 'text',
type
}),
],
}
Я хочу, чтобы тегом можно было отметить «Заметки» и «Посты». Причем, со связью «many-to-many». Для этого я укажу в схемах для типов Posts и Notes референс.
...
defineField({
: 'tags',
name: 'Tags',
title: 'array',
type: [{type: 'reference', to: {type: 'tags'}}],
of
}),...
Моя админка выглядит так:
![Внешний вид Strapi](/_next/image?url=%2Fthe-world-of-cms%2Fimg_4.jpg&w=1920&q=75)
Миграции
А что будет, если я заполню все это данными, а потом решу, что я хочу заполнять description
как маркдаун-поле?
Ничего страшного, просто выполните вот такой код:
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 => ({
: doc._id,
id: {
patch: {fullname: doc.name},
set: ['name'],
unset// this will cause the transaction to fail if the documents has been
// modified since it was fetched.
: doc._rev
ifRevisionID
}
}))
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 в маркдаун.
Но из доки так и не ясно, какие шаги предпринимать.
![Внешний вид Strapi](/_next/image?url=%2Fthe-world-of-cms%2Fimg_5.jpg&w=1920&q=75)
Так описываются схемы:
const Notes: CollectionConfig = {
: 'notes',
slug: [
fields
{: 'title', // required
name: 'text', // required
type: true,
required
},
{: 'content',
name: 'richText',
type: 'Lexical Rich Text Editor', // в настройках проекта/типа указывается editor: lexical
label
},
]
}
Все автомагически работает с тайпскриптом, так что о типизации можно не беспокоиться. Sanity, например, начал работать с типами не так давно, поэтому большую часть приходится описывать вручную.
Хранение данных
Payload CMS умеет работать с MongoDB и Postgres. В 2024 году обещают завезти MySQLite и MySQL. Удобно, потому что, в отличие от Sanity, ты не привязываешься к черному ящику Content Lake, а хранишь данные в доступной тебе БД по описанным схемам.
Миграции
Создатели утверждают, что мигрировать данные можно с помощью двух функций.
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, а с помощью функций, например:
payload.find({
: 'notes'
collection
})
При этом в проекте должен быть 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 Price | Self-Host | Databases | TypeScript | Schema | Миграции | Режим | Запросы | |
---|---|---|---|---|---|---|---|---|
Strapi | $99 | + | SQLite, MySQL, Postgres | + | Ui | 🧐 + | Standalone | REST, GraphQL, Entity?, Query??? |
Sanity.io | $0 | + | Content-Lake (своя) | + | Code | 🤢 + | Embedded, Standalone | GROQ, GraphQL |
Payload CMS | $35 | + | Postgres, MongoDB | +++! | Code | 🙂 + | Embedded, Standalone | REST, 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 до картины своей мечты.