Hooks
Run your own code before and after items are created, updated, or deleted. Validate, transform, sync, notify, or block.
Hooks are functions on a section's hooks option. They run on the server, inside the admin's request, with the item's values.
export default hasItemsSection({
// ...
hooks: {
beforeCreate: ({ values }) => { /* validate or modify */ },
afterCreate: async ({ itemId, values }) => { /* sync, notify */ },
beforeUpdate: ({ itemId, values }) => {},
afterUpdate: async ({ itemId, values }) => {},
beforeDelete: ({ itemId, values }) => {},
afterDelete: async ({ itemId, values }) => {},
onError: ({ errorMessage, values }) => {},
},
})The hooks
| Hook | Runs | Throwing... | Typical use |
|---|---|---|---|
beforeCreate | After validation, before the INSERT | Cancels the create. The error message is shown to the editor. | Business rules, derived values |
afterCreate | After the insert and file uploads succeed | Is logged. The item stays created. | Webhooks, cache revalidation, emails |
beforeUpdate | After validation, before the UPDATE | Cancels the update. | Guards ("can't unpublish a featured post") |
afterUpdate | After a successful update | Is logged. | Revalidation, search index sync |
beforeDelete | Before the row and its files are removed | Cancels the delete. | Prevent deleting items still in use |
afterDelete | After the row and its files are removed | Is logged. | Cleanup in other systems |
onError | When a save fails after the SQL ran (for example a file couldn't be written) and was rolled back | Is logged. | Alerting |
Before hooks are guards: throw to stop the operation. After hooks are best-effort: they never undo a successful write.
Simple sections always take the update path, even on their first save, so only beforeUpdate, afterUpdate and onError run for them.
Hook context
Every hook receives one object:
| Property | Type | Available in | Description |
|---|---|---|---|
values | Record<string, unknown> | all | Column → value. In create/update hooks: the values about to be written (after validation, so passwords are already hashed and uploads have their generated file names). In delete hooks: the row as stored. |
itemId | string | number | all except beforeCreate | The item's identifier. afterCreate gets the newly inserted id. |
section | Section | all | The built section instance (name, table, fields...). |
locale | string | when triggered by a translation | The locale code. See Hooks and translations. |
errorMessage, error | string, unknown | onError | What went wrong. |
Examples
Block invalid data with a readable message
hooks: {
beforeCreate: ({ values }) => {
if (Number(values.discount_price) >= Number(values.price)) {
throw new Error('The discount price must be lower than the regular price.')
}
},
}The editor sees your message in the form's error banner, and nothing is written.
Share one guard between create and update
import type { BeforeCreateContext, BeforeUpdateContext } from 'nextjs-cms/core/sections'
function validateDates({ values }: BeforeCreateContext | BeforeUpdateContext) {
if (values.ends_at && values.starts_at && String(values.ends_at) < String(values.starts_at)) {
throw new Error('The event must end after it starts.')
}
}
hooks: {
beforeCreate: validateDates,
beforeUpdate: validateDates,
}Transform values before they are saved
values is the same object used to build the SQL statement, so assigning to an existing key in a before* create/update hook changes what's saved:
hooks: {
beforeCreate: ({ values }) => {
// Normalize user input
values.email = String(values.email ?? '').trim().toLowerCase()
},
beforeUpdate: ({ values }) => {
values.email = String(values.email ?? '').trim().toLowerCase()
},
}Only modify keys that are already in values (the section's own columns). Adding a key for a column that isn't in the table makes the SQL statement fail.
Revalidate your website's cache
async function revalidateSite() {
await fetch(`${process.env.SITE_URL}/api/revalidate`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.REVALIDATE_SECRET}` },
body: JSON.stringify({ tag: 'posts' }),
})
}
hooks: {
afterCreate: revalidateSite,
afterUpdate: revalidateSite,
afterDelete: revalidateSite,
}The official @nextjscms/plugin-cache-revalidation plugin packages this pattern with retries, a failure log, and a page to re-send failed purges: hooks: siteRevalidationHooks('posts').
Prevent deleting items that are still in use
import { sql } from 'drizzle-orm'
import { db } from 'nextjs-cms/db/client'
hooks: {
beforeDelete: async ({ itemId }) => {
const [rows] = await db.execute(sql`SELECT COUNT(*) AS n FROM products WHERE brand_id = ${itemId}`)
const count = Number((rows as unknown as { n: number }[])[0]?.n ?? 0)
if (count > 0) {
throw new Error(`This brand is used by ${count} product(s). Reassign them first.`)
}
},
}Send a webhook with the created item
hooks: {
afterCreate: async ({ itemId, values, section }) => {
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `New ${section.name} #${itemId}: ${values.title} (by ${values.created_by})`,
}),
})
},
}Report failures
hooks: {
onError: ({ section, itemId, errorMessage }) => {
console.error(`[${section.name}] save failed for item ${itemId ?? '(new)'}: ${errorMessage}`)
},
}Hooks and translations
When content localization is on, saving a translation (a non-default locale) is a separate operation from saving the item. By default, hooks do not run for translations. To opt in, use the object form with runForLocales: true:
hooks: {
// Runs for the default locale only
beforeUpdate: ({ values }) => { /* ... */ },
// Runs for the default locale AND every translation save
afterUpdate: {
runForLocales: true,
handler: async ({ itemId, locale }) => {
await revalidate(`posts:${itemId}:${locale ?? 'default'}`)
},
},
}For translation saves:
localeis set to the translation's locale code (it'sundefinedfor default-locale saves).valuesholds only the localized columns.- Creating a translation for the first time runs
beforeCreate/afterCreate. Editing an existing translation runs the update hooks. - Deleting one translation runs
beforeDelete/afterDeletewithrunForLocales: trueonly.
Types
import type {
Hooks,
HookFn,
BeforeCreateContext,
AfterCreateContext,
BeforeUpdateContext,
AfterUpdateContext,
BeforeDeleteContext,
AfterDeleteContext,
OnErrorContext,
} from 'nextjs-cms/core/sections'
// A hook is either a function, or an object with a handler and runForLocales.
type HookFnShape<T> =
| ((ctx: T) => Promise<void> | void)
| { handler: (ctx: T) => Promise<void> | void; runForLocales?: boolean }