Site icon Ampersand Tutorials

Payload CMS Hooks and Automation Guide (2026)

Payload CMS hooks and automation lifecycle illustration

Quick answer: Payload CMS hooks and automation keep a growing content codebase consistent without manual editorial steps. Hooks are plain functions that run at fixed points in a document’s lifecycle, and they are the correct place for cross-field validation, derived values, uniqueness stamping, and syncing side effects like revalidation, email, or search indexing. This article walks through the six hooks you actually use (beforeValidate, beforeOperation, beforeChange, afterChange, afterRead, afterDelete), what to do in each, and how scheduled publishing works with a mechanism Payload does not ship built in.

Part 8 of our Payload CMS series. Start with what Payload CMS is and install Payload and build your first collection, then read globals, hooks and reused collections for the picks of the right hook before you write one.

1. The six Payload CMS hooks you will actually use

Payload’s docs list more hooks than you will use weekly. These six cover nearly everything:

HookRunsDo this here
beforeValidateBefore field validation runsNormalize input (trim, lowercase email), reject impossible shapes early
beforeOperationBefore the whole operationInject defaults based on the request, guard against concurrent edits
beforeChangeBefore each document writeDerive fields (slug, reading time), enforce cross-field rules
afterChangeAfter the write commitsRevalidate pages, send notifications, sync a search index
afterReadOn each read resultInject computed display fields, mask data the client should not see
afterDeleteAfter a delete succeedsClean up media, audit-log the deletion

Two more get honorable mentions: field-level hooks (which run on a single field, scoped, and are cheaper than collection-level for simple transforms) and beforeDelete, which is where you block a delete that would orphan children.

2. beforeChange is where derivations live

Derived fields belong in beforeChange, where the data being saved is still mutable. Two patterns cover most derivations:

// collections/Posts.ts — excerpt
import { slugify } from '../lib/slugify'   // pure, unit-tested

hooks: {
  beforeChange: [
    async ({ data, originalDoc, operation }) => {
      // Pattern 1: derive once, keep stable on update
      if (operation === 'create') {
        data.slug = data.slug || slugify(data.title)
      }
      // Pattern 2: recompute only when the source changed
      if (data.content !== originalDoc?.content) {
        data.readingTime = estimateReadingTime(data.content)
      }
      return data
    },
  ],
}

The line between the two patterns matters more than it looks. A slug that silently changes every edit breaks every external link to that page; a reading time that never updates goes stale after the first edit. Decide, per derived field, whether it is written-once or kept-in-sync, and encode that decision in the hook.

3. Enforcing cross-field rules with thrown errors

Hooks that throw stop the operation. That is your enforcement tool for rules field validation cannot express, because validation runs per field while real rules are often between fields:

// A post in the review state must have editorial notes
async ({ data, originalDoc, req, operation }) => {
  if (operation !== 'update') return data
  if (data.status === 'review' && !data.editorialNotes) {
    throw new Error('Moving a post to review requires editorial notes.')
  }
  return data
}

Throwing a plain Error returns a 400-class response with your message and blocks the write. Use it for genuine impossibilities, not for formatting niceties — users should be able to save drafts while a rule still guards the final state. The access-control article showed which states admins can reach; hooks are where you also enforce transitions, not just final values.

4. afterChange: revalidate, notify, index

Side effects go after the write, and the biggest one for a Next.js site is cache revalidation. A content change has to reach the rendered site:

async ({ doc, req, operation }) => {
  if (operation !== 'update' || doc.status !== 'published') return
  // Revalidate the Next.js route for this post
  req.payload.logger.info(`revalidate /blog/${doc.slug}`)
  // then trigger your revalidation mechanism — see section 6
}

The same shape covers email (“a comment was left on your post”), search indexing (“push doc to Typesense”), webhooks (“tell Zapier”). Two rules keep these correct:

5. afterRead and masking data the client should not see

afterRead runs on every document that comes out of the database, on every operation that reads. That makes it a masking point and a shape-normalizer:

afterRead: [
  async ({ doc, req }) => {
    if (!req.user || req.user.role !== 'admin') {
      // Non-admin readers get a summary, not the full internal note
      doc.internalNotes = doc.internalNotes?.slice(0, 80) + '...'
    }
    return doc
  },
]

Two cautions: masking in afterRead is presentation-level, not security — an unauthorized reader who gets the full document through a code path that skips this hook is not protected. For real protection, field-level access from the auth article is the control that enforces at the query itself. Also, hooks at read time run per document; heavy computation here is multiplied over whole collection queries.

6. Scheduled publishing: Payload CMS hooks and automation beyond the editor

Here is the honest answer: Payload has no built-in cron scheduler. You can model publishDate as a date field, but nothing in Payload core wakes up at that timestamp to flip the status. The supported pattern is your own external trigger plus the Local API:

// scripts/publish-by-date.ts — run by cron / scheduled function
import { getPayload } from 'payload'
import config from '@payload-config'

const payload = await getPayload({ config })
const due = await payload.find({
  collection: 'posts',
  where: {
    status: { equals: 'draft' },
    publishDate: { less_than_equal: new Date().toISOString() },
  },
  overrideAccess: true,
  depth: 0,
})
for (const post of due.docs) {
  await payload.update({
    collection: 'posts',
    id: post.id,
    data: { status: 'published' },
    overrideAccess: true,
  })
}

Schedule that script with whatever scheduling your host provides — a cron on a VPS, a Vercel scheduled function, or a GitHub Actions schedule. The two rules that keep it safe:

If your editorial team needs finer control, the admin-panel route is still the primary place editors publish; the scheduler exists for posts whose timing is pre-decided.

7. Testing hooks

Extract and test the pure parts, integration-test the lifecycles:

// lib/slugify.test.ts — pure, no Payload
import { slugify } from './slugify'

test('slugify handles unicode and spaces', () => {
  expect(slugify('Héllo Wörld')).toBe('hello-world')
})

For lifecycle behavior (does status: review really block without notes?), seed an isolated Payload instance and drive operations through the Local API, then assert either the thrown error or the changed document. You are testing behavior, not Payload itself — focus assertions on your rules.

8. The mistakes you will actually make

MistakeWhy it bitesFix
Side effects in beforeChangeA slow webhook makes every save slow; a throwing one blocks a commit the DB should acceptMove to afterChange, catch and log
Deriving on every operation instead of on changeA hook reading data.content !== originalDoc.content also fires when only status changedCheck what actually changed before recomputing
Reading the doc you are saving from the DB in beforeChangeThe DB still holds the old row; you wanted dataUse the data argument; originalDoc is the previous state
Triggering on every field saveMultiple hooks at different nesting levels → side effect fires twiceRegister a hook exactly once per concern
Masking secrets in afterReadPresentation masking is not access controlUse field-level access for real protection
Relying on hooks for schemaA hook assumes a field exists; prod sync never ranMigrations still manage schema — run them in deploy

9. Key takeaways and challenge

Challenge: take the Posts collection from the earlier articles and add a beforeChange hook that refuses to publish a post whose author relationship is unset — the rule is a transition rule, so allow unpublished saves to proceed. Then write an idempotent scheduled-publish script for publishDate, and one pure helper it relies on. Finally, add an afterChange hook that logs when a post moves from draft to published but logs which state it left — that requires reading the document from the operation’s previous state, and it is worth writing down whether your first attempt passed a test before you touched it.

*Building automated publishing workflows on a real content stack? Ampersand Academy runs one-to-one mentoring on TypeScript and Next.js backends, including hook and workflow design. Built and run by Mahadhi — development and digital marketing.

Which hook should validate that one field depends on another?

beforeChange with a thrown error for transition rules that need the final values of all fields. beforeValidate runs before field validation and is best for normalizing input, not for rules that depend on validated values.

Do hooks run on every operation type?

Collection-level hooks like beforeChange and afterChange fire on create and update, and field-level variants can run per field. Read hooks fire on every read including admin queries, so read-time work is multiplied by document count.

How do I prevent a hook from double running?

Register it once. Hooks defined both on a field and its collection config at overlapping points, or imported into two places, can double-run. Keep one concern to one registration site.

Is scheduled publishing built into Payload?

No. Payload gives you the date field and Local API updates; the cron tick that flips status is your job. A scheduled function or cron script calls payload update with overrideAccess true on matched posts, and should be idempotent.

What happens if an afterChange hook throws?

The database write has already committed, so data is safe, but the operation reports failure. Catch and log inside afterChange for side effects that must not break saves.

How do I run a hook only when certain fields changed?

Compare the incoming data to originalDoc inside beforeChange and return early when the relevant field is unchanged. This is the standard guard for derived fields like a reading time.

Should heavy work run inside a hook?

Not in a blocking hook. Anything slow or failure-prone such as email, external APIs, or bulk indexing belongs in afterChange wrapped in try and catch, or better in a queued background job triggered from the hook.

Exit mobile version