Custom Payload CMS Hooks
Hooks in Payload are the primary mechanism for extending business logic. They execute at specific stages of the document lifecycle: before/after read, create, change, delete. Hooks are async TypeScript functions with full type safety.
Collection Hook Types
// collections/Orders.ts
const Orders: CollectionConfig = {
slug: 'orders',
hooks: {
beforeOperation: [/* validation before operation */],
beforeValidate: [/* data transformation before validation */],
beforeChange: [/* data transformation */],
afterChange: [/* side effects after save */],
beforeRead: [/* data filtering on read */],
afterRead: [/* data enrichment after read */],
beforeDelete: [/* pre-deletion checks */],
afterDelete: [/* cleanup after deletion */],
},
}
beforeChange Hooks: Data Transformation
import type { CollectionBeforeChangeHook } from 'payload/types'
const generateOrderNumber: CollectionBeforeChangeHook = async ({
data,
req,
operation,
}) => {
if (operation === 'create') {
// Generate order number
const count = await req.payload.count({ collection: 'orders' })
data.orderNumber = `ORD-${String(count + 1).padStart(6, '0')}`
// Set author
if (req.user) {
data.createdBy = req.user.id
}
// Timestamp
data.createdAt = new Date().toISOString()
}
return data
}
const validateStock: CollectionBeforeChangeHook = async ({ data, req }) => {
// Check product availability before creating order
for (const item of data.items || []) {
const product = await req.payload.findByID({
collection: 'products',
id: item.product,
})
if (product.stock < item.quantity) {
throw new Error(`Product "${product.name}": insufficient stock`)
}
}
return data
}
afterChange Hooks: Side Effects
import type { CollectionAfterChangeHook } from 'payload/types'
const sendOrderConfirmation: CollectionAfterChangeHook = async ({
doc,
operation,
req,
}) => {
if (operation === 'create') {
// Send email notification
await emailService.send({
to: doc.customerEmail,
subject: `Order #${doc.orderNumber} confirmed`,
template: 'order-confirmation',
data: { order: doc },
})
}
if (operation === 'update' && doc.status === 'shipped') {
await emailService.send({
to: doc.customerEmail,
subject: `Order #${doc.orderNumber} shipped`,
template: 'order-shipped',
data: { order: doc, trackingNumber: doc.trackingNumber },
})
}
}
const revalidateCache: CollectionAfterChangeHook = async ({ doc }) => {
// Invalidate Next.js ISR cache
const paths = [`/products/${doc.slug}`, '/products']
await Promise.all(
paths.map(path =>
fetch(`${process.env.NEXT_PUBLIC_SERVER_URL}/api/revalidate`, {
method: 'POST',
headers: { 'x-revalidate-secret': process.env.REVALIDATE_SECRET! },
body: JSON.stringify({ path }),
})
)
)
}
const syncWithCRM: CollectionAfterChangeHook = async ({ doc, operation }) => {
if (operation === 'create') {
// Create deal in CRM
await crmClient.deals.create({
title: `Order #${doc.orderNumber}`,
amount: doc.total,
contactEmail: doc.customerEmail,
})
}
}
afterRead Hooks: Data Enrichment
import type { CollectionAfterReadHook } from 'payload/types'
const addComputedFields: CollectionAfterReadHook = async ({ doc }) => {
// Calculate total fields not stored in DB
if (doc.items) {
doc.subtotal = doc.items.reduce(
(sum: number, item: any) => sum + item.price * item.quantity,
0
)
doc.totalItems = doc.items.length
}
return doc
}
beforeDelete Hooks: Deletion Protection
import type { CollectionBeforeDeleteHook } from 'payload/types'
const preventDeleteWithOrders: CollectionBeforeDeleteHook = async ({
id,
req,
}) => {
// Prevent customer deletion with active orders
const orders = await req.payload.find({
collection: 'orders',
where: {
and: [
{ customer: { equals: id } },
{ status: { not_in: ['completed', 'cancelled'] } },
],
},
limit: 1,
})
if (orders.totalDocs > 0) {
throw new Error('Cannot delete customer with active orders')
}
}
Global Hooks
const Settings: GlobalConfig = {
slug: 'settings',
hooks: {
afterChange: [
async ({ doc }) => {
// Invalidate entire site when settings change
await fetch('/api/revalidate?path=/', { method: 'POST' })
},
],
},
}
Reusable Hooks
// hooks/shared/timestamps.ts
import type { CollectionBeforeChangeHook } from 'payload/types'
export const setTimestamps: CollectionBeforeChangeHook = ({ data, operation }) => {
if (operation === 'create') {
data.createdAt = new Date().toISOString()
}
data.updatedAt = new Date().toISOString()
return data
}
// In collections:
hooks: {
beforeChange: [setTimestamps, ...otherHooks],
}
Error Handling in Hooks
const validateHook: CollectionBeforeChangeHook = async ({ data }) => {
try {
await externalValidationService.validate(data)
} catch (error) {
if (error instanceof ValidationError) {
// Payload will show error in admin UI
throw new Error(`Validation failed: ${error.message}`)
}
// Log unexpected errors without blocking save
console.error('External validation error:', error)
}
return data
}
Timeline
Set of hooks for collection business logic (email notifications, CRM sync, validation) — 1–2 days.







