| name | hooks |
| description | This skill should be used when the user asks about "Payload hooks", "beforeChange", "afterChange", "afterRead", "beforeDelete", "field hooks", "global hooks", "prevent hook loops", "Next.js revalidation in Payload", "transaction safe hooks", "auto-set author from req.user", or needs to wire up lifecycle automation in PayloadCMS. |
PayloadCMS — Hooks
Hooks let you inject logic at every step of a document's lifecycle. They live on collections, globals, and individual fields. Use them for slug generation, audit logs, cache invalidation, side effects (email, webhooks), and computed values — without forking the admin or the API.
Hook Categories
| Where | Available hooks |
|---|
| Collection | beforeOperation, beforeValidate, beforeChange, afterChange, beforeRead, afterRead, beforeDelete, afterDelete, afterOperation, afterError, plus auth-only: beforeLogin, afterLogin, afterLogout, afterForgotPassword, afterMe, afterRefresh |
| Global | beforeValidate, beforeChange, afterChange, beforeRead, afterRead |
| Field | beforeValidate, beforeChange, afterChange, afterRead |
Every hook is an array — Payload runs them in order. Plugins append to these arrays; never overwrite.
Hook Argument Shape
Every collection hook receives roughly the same args (precise shape varies per hook). Common keys:
{
doc,
data,
previousDoc,
operation,
req,
context,
collection,
originalDoc,
}
req.payload is the Payload instance — use it for nested DB calls. Always pass req through:
await req.payload.update({ collection: 'audit', data: {}, req })
beforeChange — runs on create + update
The most common hook. Use it to:
- Normalize / sanitize values.
- Generate derived fields (slugs, search-index strings).
- Set
author = req.user.id automatically.
- Reject invalid combinations with a thrown error.
import slugify from 'slugify'
hooks: {
beforeChange: [
async ({ data, operation, req }) => {
if (operation === 'create' && data.title && !data.slug) {
data.slug = slugify(data.title, { lower: true, strict: true })
}
if (operation === 'create' && req.user) {
data.author = req.user.id
}
return data
},
],
}
Throwing inside beforeChange aborts the operation. Use APIError for proper HTTP responses:
import { APIError } from 'payload'
if (data.price < 0) {
throw new APIError('Price cannot be negative.', 400, undefined, true)
}
afterChange — runs after persist
Side effects only. The change is already saved.
hooks: {
afterChange: [
async ({ doc, previousDoc, operation, req, collection }) => {
if (doc._status === 'published') {
revalidatePath(`/${collection.slug}/${doc.slug}`)
}
await req.payload.create({
collection: 'audit-log',
data: {
action: operation,
collection: collection.slug,
docId: doc.id,
userId: req.user?.id,
},
req,
})
return doc
},
],
}
afterRead — transform on every fetch
Runs on every find / findByID / REST / GraphQL response. Heavy compute here can wreck performance — cache when possible.
hooks: {
afterRead: [
({ doc }) => {
doc.computedExcerpt = (doc.content || '').slice(0, 200)
return doc
},
],
}
Common use cases: hide sensitive fields, decorate with derived values, populate from external APIs.
beforeDelete — cascading cleanup
hooks: {
beforeDelete: [
async ({ id, req }) => {
await req.payload.delete({
collection: 'comments',
where: { post: { equals: id } },
req,
})
},
],
}
beforeValidate — coerce / normalize
Runs before field validators. Last chance to fix incoming data.
hooks: {
beforeValidate: [
({ data }) => {
if (typeof data.tags === 'string') {
data.tags = data.tags.split(',').map((t) => t.trim())
}
return data
},
],
}
afterError — capture failures
hooks: {
afterError: [
async ({ error, req }) => {
req.payload.logger.error({ msg: 'collection error', err: error })
},
],
}
Auth-only Hooks
For collections with auth: true:
hooks: {
beforeLogin: [
async ({ user, req }) => {
if (user.disabled) {
throw new APIError('Account disabled', 403)
}
},
],
afterLogin: [
async ({ user, req }) => {
await req.payload.update({
collection: 'users',
id: user.id,
data: { lastLoginAt: new Date().toISOString() },
req,
})
},
],
afterForgotPassword: [
async ({ args }) => {
},
],
}
Field Hooks
Same shapes as collection hooks but scoped to one field:
{
name: 'slug',
type: 'text',
hooks: {
beforeValidate: [
({ data, value }) => value || slugify(data?.title || ''),
],
afterRead: [
({ value }) => value?.toLowerCase(),
],
},
}
Field hooks fire inside the surrounding collection hook step — i.e., all field beforeChange hooks run during the collection's beforeChange phase.
Global Hooks
Globals have a single document, so there's no beforeDelete. Otherwise same args:
export const Header: GlobalConfig = {
slug: 'header',
fields: [],
hooks: {
afterChange: [
async ({ doc }) => {
revalidateTag('global-header')
},
],
},
}
Critical Patterns
Thread req through nested ops to keep transactions atomic
afterChange: [
async ({ doc, req }) => {
await req.payload.create({
collection: 'audit',
data: { },
})
},
]
afterChange: [
async ({ doc, req }) => {
await req.payload.create({
collection: 'audit',
data: { },
req,
})
},
]
Without req, the nested write commits separately — if the parent rolls back, your "side effect" doesn't.
Prevent infinite hook loops with context
afterChange: [
async ({ doc, req, context }) => {
if (context?.skipHooks) return doc
await req.payload.update({
collection: 'posts',
id: doc.id,
data: { viewCount: (doc.viewCount || 0) + 1 },
context: { skipHooks: true },
req,
})
return doc
},
]
req.context is a plain object passed through the whole request — your skip flags, cache keys, or correlation IDs live here.
Next.js revalidation with context control
import { revalidatePath, revalidateTag } from 'next/cache'
hooks: {
afterChange: [
({ doc, req, context }) => {
if (context?.disableRevalidate) return doc
if (doc._status === 'published') {
revalidatePath(`/posts/${doc.slug}`)
revalidateTag('posts')
}
return doc
},
],
afterDelete: [
({ doc }) => {
revalidatePath(`/posts/${doc.slug}`)
revalidateTag('posts')
},
],
}
When you want to bulk-write without triggering ISR: payload.update({ ..., context: { disableRevalidate: true }, req }).
Logger Usage
Use payload.logger for structured logs that survive transactions:
req.payload.logger.error({ msg: 'sync failed', err })
req.payload.logger.info('cache warmed')
Where Hooks Live
src/collections/
├── Posts.ts
├── Posts.hooks/
│ ├── slugify.ts
│ ├── stampAuthor.ts
│ ├── revalidate.ts
│ └── index.ts
Importing from a sibling folder keeps the collection file readable:
import { slugify, stampAuthor, revalidate } from './Posts.hooks'
export const Posts: CollectionConfig = {
hooks: {
beforeChange: [slugify, stampAuthor],
afterChange: [revalidate],
},
}
After every hooks change run pnpm generate:types only if you changed field shapes — hook code is plain TS, not stored in the config.