Пять ловушек Payload CMS: что показал разбор 11 проектов
Мы разобрали 11 проектов на Payload CMS: production-системы, собственные проекты и официальный монорепозиторий Payload.
Интереснее всего оказались не «лучшие практики», а места, где архитектурное решение уже привело к конкретной проблеме.
Ниже — пять выводов, которые действительно стоит проверить в своём проекте на Payload.
1. Не делайте собственный draft/published поверх Payload
В одном из проектов публикация была реализована обычным select-полем:
status: 'draft' | 'published'При этом коллекция имела публичный доступ:
read: () => trueВ интерфейсе всё выглядело правильно: редактор видел черновик как черновик.
Но REST API спокойно отдавал этот документ наружу.
Проблема здесь не столько в ошибке access control, сколько в самой архитектуре: поверх Payload построили второй механизм публикации.
У Payload уже есть штатный:
versions: {
drafts: true,
}Он добавляет _status, хранит версии и нормально интегрируется с Draft Mode.
Если требуется обычная модель «черновик → публикация», собственное поле status почти всегда создаёт лишнее состояние, которое затем приходится синхронизировать с access control, preview и фронтендом.
Правило: публикация через versions.drafts. Кастомный workflow оправдан, только когда нужен процесс сложнее стандартного draft/published.
2. Вызов Payload из хука без `req` может закончиться deadlock
Это уже менее очевидная ловушка.
В одном из проектов внутри хука выполнялась вложенная операция:
await payload.create({
collection: 'something',
data,
})Сохранение документа периодически зависало на сотни секунд.
Причина: вложенная операция не получила req текущего запроса и открыла работу с базой вне уже существующей транзакции.
Правильный вариант:
await req.payload.create({
collection: 'something',
data,
req,
})или эквивалентный вызов с передачей текущего req.
Это особенно важно для операций из beforeChange, afterChange и других хуков, которые выполняются внутри транзакционного контекста.
После такого бага правило лучше не оставлять только в документации проекта. Его стоит проверять автоматически — тестом или линтером.
Правило: вложенная операция Payload внутри хука должна использовать текущий req.
3. Local API по умолчанию обходит access control
Это одна из самых важных особенностей Payload, потому что код выглядит совершенно безопасно:
await payload.find({
collection: 'orders',
})Но Local API по умолчанию работает с overrideAccess: true.
То есть access-функции коллекции здесь могут вообще не выполниться.
Для системной серверной операции это удобно.
Для запроса от конкретного пользователя — потенциально опасно.
Если результат должен зависеть от прав пользователя:
await payload.find({
collection: 'orders',
overrideAccess: false,
user: req.user,
})Особенно внимательно это стоит проверять в:
- личных кабинетах;
- мультитенантных проектах;
- API для сотрудников с разными ролями;
- серверных actions и route handlers.
REST API и Local API в этом смысле имеют разную модель доверия.
Правило: если Local API выполняет действие от имени пользователя, передавайте user и явно ставьте overrideAccess: false.
4. `force-dynamic` обычно не нужен для страниц из Payload
Очень соблазнительная конструкция:
export const dynamic = 'force-dynamic'Контент редактируется через CMS, значит страница должна быть всегда свежей.
В нескольких рассмотренных проектах эта логика привела к тому, что практически весь сайт оказался dynamic. В одном случае — вплоть до robots.txt и sitemap.xml.
Но Payload хорошо ложится на другую модель:
изменили документ
↓
afterChange
↓
revalidatePath / revalidateTag
↓
следующий запрос получает новую страницуНапример:
afterChange: [
({ doc, req }) => {
revalidatePath(`/posts/${doc.slug}`)
},
]Для публичного сайта это обычно выгоднее тотального SSR: большую часть времени страница отдаётся из кеша, а изменение контента само инвалидирует нужный маршрут.
При этом нужен и afterDelete, иначе удалённая страница может остаться в кеше.
force-dynamic имеет смысл оставлять там, где HTML действительно зависит от конкретного запроса: кабинет, авторизованный пользователь, персонализированные данные.
Правило: публичный контент Payload — кешировать и инвалидировать по событию, а не рендерить заново на каждый просмотр.
5. Не тащите `'use client'` на уровень CMS-блоков
У Payload очень естественно строить страницы из blocks:
blocks: [
Hero,
Content,
Gallery,
Form,
]А затем сделать общий renderer.
Проблема начинается, если сам renderer получает:
'use client'Тогда всё поддерево блоков становится клиентским.
Причём даже:
- обычный текст;
- заголовки;
- SEO-контент;
- статические изображения;
- блоки без единого обработчика событий.
Такой подход обнаружился даже в сильных референсных проектах, поэтому копировать архитектуру чужого Payload-проекта вслепую не стоит.
Нормальная граница обычно находится ниже:
Page
└── RenderBlocks ← Server Component
├── Hero ← Server Component
├── Content ← Server Component
├── Gallery
│ └── Slider ← Client Component
└── Map
└── InteractiveMap ← Client ComponentПравило: CMS-блок по умолчанию серверный. 'use client' спускается максимально близко к конкретной интерактивности.
Бонус: структура проекта важнее AGENTS.md
В проектах, которые активно пишутся coding-агентами, повторяется ещё одна проблема.
Можно написать в AGENTS.md:
Все access-функции хранить в src/access.Но если рядом с коллекцией уже лежит:
access: {
read: ({ req }) => {
// ещё одна уникальная реализация
},
}агент почти наверняка продолжит существующий паттерн.
То же самое происходит с получением данных, ревалидацией и повторяющимися полями.
Поэтому наиболее устойчивые проекты не только описывают правила, но и оставляют один очевидный путь:
src/
├── access/
├── fields/
│ ├── slug.ts
│ └── seo.ts
├── hooks/
│ └── revalidate.ts
└── lib/
└── cms.tsА вещи, которые можно проверить автоматически, вообще не оставляют на усмотрение агента.
Например, после инцидента с транзакцией можно проверять, что вложенные Payload-операции в хуках получают req.
Правило: архитектурная конвенция, которую можно нарушить автоматически, должна автоматически и проверяться.
Что проверить прямо сейчас
Если проект уже работает на Payload 3, я бы начал с пяти поисков по репозиторию:
status: 'draft'
overrideAccess
force-dynamic
'use client'
payload.create(И проверил:
- нет ли самодельной публикации там, где достаточно
versions.drafts; - не выполняются ли user-scoped Local API вызовы с обходом access control;
- передаётся ли
reqво вложенные операции из хуков; - не отключён ли кеш целиком ради мгновенного обновления контента;
- не превращён ли весь renderer CMS-блоков в Client Component.
Это небольшие решения, но именно из таких решений потом складывается разница между «сайт работает» и Payload-проектом, который остаётся предсказуемым после нескольких лет разработки.