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, // Current document (after change for afterChange, before for beforeChange)
data, // Incoming data (beforeChange) or null
previousDoc, // Document state before update
operation, // 'create' | 'update'
req, // Payload Request — contains payload, user, locale, transactionID, context, headers
context, // req.context — your own flags
collection, // SanitizedCollectionConfig
originalDoc, // Doc as fetched from DB (pre-mutation)
}
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.idautomatically. - Reject invalid combinations with a thrown error.
import slugify from 'slugify'
hooks: {
beforeChange: [
async ({ data, operation, req }) => {
// Auto-slugify title on create
if (operation === 'create' && data.title && !data.slug) {
data.slug = slugify(data.title, { lower: true, strict: true })
}
// Stamp the author from the authenticated user
if (operation === 'create' && req.user) {
data.author = req.user.id
}
return data // Always 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 }) => {
// Cache invalidation (see Next.js Revalidation section)
if (doc._status === 'published') {
revalidatePath(`/${collection.slug}/${doc.slug}`)
}
// Write to audit log — pass req to keep the same transaction
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 }) => {
// Cascade: delete all comments belonging to this post
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 })
// Optional: forward to Sentry, etc.
},
],
}
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 }) => {
// Custom email or webhook
},
],
}
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
// ❌ BREAKS TRANSACTION — runs in its own transaction
afterChange: [
async ({ doc, req }) => {
await req.payload.create({
collection: 'audit',
data: { /*…*/ },
// missing req!
})
},
]
// ✅ ATOMIC — joins the calling transaction
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 // Bail out re-entrant
await req.payload.update({
collection: 'posts',
id: doc.id,
data: { viewCount: (doc.viewCount || 0) + 1 },
context: { skipHooks: true }, // Tell ourselves not to re-fire
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 }) // ✅ object form
req.payload.logger.info('cache warmed') // ✅ string form
// ❌ Invalid: don't pass the error as a 2nd arg
// req.payload.logger.error('something failed', err)
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.