Пять ловушек Payload CMS: что показал разбор 11 проектов

15 сентября 2026 г.Александр Андреев

Мы разобрали 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(

И проверил:

  1. нет ли самодельной публикации там, где достаточно versions.drafts;
  2. не выполняются ли user-scoped Local API вызовы с обходом access control;
  3. передаётся ли req во вложенные операции из хуков;
  4. не отключён ли кеш целиком ради мгновенного обновления контента;
  5. не превращён ли весь renderer CMS-блоков в Client Component.

Это небольшие решения, но именно из таких решений потом складывается разница между «сайт работает» и Payload-проектом, который остаётся предсказуемым после нескольких лет разработки.

Назад в блог
Пять ловушек Payload CMS: что показал разбор 11 проектов | Payload по-русски