nextjs-cms

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.

sections/posts.section.ts
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

HookRunsThrowing...Typical use
beforeCreateAfter validation, before the INSERTCancels the create. The error message is shown to the editor.Business rules, derived values
afterCreateAfter the insert and file uploads succeedIs logged. The item stays created.Webhooks, cache revalidation, emails
beforeUpdateAfter validation, before the UPDATECancels the update.Guards ("can't unpublish a featured post")
afterUpdateAfter a successful updateIs logged.Revalidation, search index sync
beforeDeleteBefore the row and its files are removedCancels the delete.Prevent deleting items still in use
afterDeleteAfter the row and its files are removedIs logged.Cleanup in other systems
onErrorWhen a save fails after the SQL ran (for example a file couldn't be written) and was rolled backIs logged.Alerting

Before hooks are guards: throw to stop the operation. After hooks are best-effort: they never undo a successful write.

Good to know:

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:

PropertyTypeAvailable inDescription
valuesRecord<string, unknown>allColumn → 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.
itemIdstring | numberall except beforeCreateThe item's identifier. afterCreate gets the newly inserted id.
sectionSectionallThe built section instance (name, table, fields...).
localestringwhen triggered by a translationThe locale code. See Hooks and translations.
errorMessage, errorstring, unknownonErrorWhat 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()
    },
}
Warning:

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,
}
Tip:

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:

  • locale is set to the translation's locale code (it's undefined for default-locale saves).
  • values holds 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 / afterDelete with runForLocales: true only.

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 }