Lifecycle Hooks

Lifecycle hooks allow you to execute custom logic before and after CRUD operations. Perfect for audit logging, notifications, data transformation, and business logic.

Lifecycle Hooks

Lifecycle hooks allow you to execute custom logic before and after CRUD operations. Perfect for audit logging, notifications, data transformation, and business logic.

Available Hooks

HookWhen it runsCan modify dataUse cases
beforeCreateBefore inserting record✅ YesSet defaults, validate, transform
afterCreateAfter inserting record✅ Response (return a replacement; not on bulk routes)Send notifications, log audit
beforeUpdateBefore updating record✅ YesValidate changes, transform
afterUpdateAfter updating record✅ Response (return a replacement; not on bulk routes)Notify users, sync services
beforeDeleteBefore deleting record❌ NoCheck dependencies, validate
afterDeleteAfter deleting record❌ NoClean up relations, log audit
beforeListBefore listing records❌ NoLog access (rarely needed)
afterListAfter listing records✅ Response (return a replacement array)Log access, decorate rows
beforeGetBefore fetching single record — (id, ctx)❌ NoLog access (rarely needed)
afterGetAfter fetching single record✅ Response (return a replacement)Track views, decorate
beforeM2MSync / beforeM2MAddBefore linking — (relation, ids, ctx); may return replacement ids✅ IdsEnforce link rules
afterM2MSync / afterM2MAdd / afterM2MRemoveAfter an M2M write — (relation, result, ctx)❌ NoAudit, notify
beforeM2MRemoveBefore unlinking — (relation, ids, ctx)❌ NoGuard removals

The playground's hooks page illustrates the order in which hooks run around each request; its activity feed is drawn in the browser — the playground registers no server hooks.

Hook Configuration Methods

Hooks are registered with the resource (in a module) or at runtime (in a Nitro plugin).

Method 1: Per-Resource (via Module Registration)

Best for module-based architecture. These run last, after plugin hooks.

// modules/base/index.ts
import { defineNuxtModule, createResolver } from '@nuxt/kit'
import { createModuleImport } from '@websideproject/nuxt-auto-api'

export default defineNuxtModule({
  setup(options, nuxt) {
    const resolver = createResolver(import.meta.url)

    nuxt.hook('autoApi:registerSchema', (registry) => {
      registry.register('users', {
        schema: createModuleImport(resolver.resolve('./schema'), 'users'),
        authorization: createModuleImport(resolver.resolve('./auth'), 'usersAuth'),
        hooks: createModuleImport(resolver.resolve('./hooks'), 'userHooks'),
      })
    })
  },
})
// modules/base/hooks.ts
import type { ResourceHooks } from '@websideproject/nuxt-auto-api'

export const userHooks: ResourceHooks = {
  beforeCreate: async (data, context) => {
    data.status = data.status || 'active'
    return data
  },

  afterCreate: async (user, context) => {
    console.log(`User ${user.id} created by ${context.user?.id ?? 'system'}`)
    // Send welcome email, create audit log, etc.
  },
}
A hooks file registered with createModuleImport is not processed by Nitro's auto-imports: import what you use (createError from h3, eq from drizzle-orm, your tables) and read config from context.runtimeConfig, not useRuntimeConfig(). Hooks registered from a Nitro plugin (Method 2) have the auto-imports.

Method 2: Plugin-Based (Runtime Registration)

Best for cross-cutting concerns, or hooks that live in the app rather than in a module:

// server/plugins/user-hooks.ts
import { addResourceHook, addGlobalHook } from '@websideproject/nuxt-auto-api/plugins'

export default defineNitroPlugin(() => {
  addResourceHook('users', {
    afterCreate: async (user, context) => {
      await sendEmail({ to: user.email, template: 'welcome', data: { name: user.name } })
    },
    afterUpdate: async (user) => {
      await clearCache(`user:${user.id}`)
    },
  })

  // Every resource
  addGlobalHook({
    afterDelete: async (id, context) => {
      console.log(`${context.resource} ${id} deleted by ${context.user?.id}`)
    },
  })
})

Assigning functions onto globalThis.__autoApiHooks[resource] (the older form) still works and runs first.

Hooks cannot be declared in nuxt.config.ts: config reaches the server through runtimeConfig, which is serialized, so functions never arrive. Setting autoApi.hooks fails the build — use one of the two methods above.

Hook Priority & Merging

When multiple hooks exist for the same event:

  1. Legacy hooks (globalThis.__autoApiHooks[resource]) run first
  2. Plugin hooks (addGlobalHook / addResourceHook) next
  3. Registry hooks (the resource's hooks via createModuleImport) run last

A before* hook that returns data passes it to the next hook; the last returned value is written.

Before Hooks (Data Modification)

Before hooks can modify data before it's saved:

import { createError } from 'h3'

export const productHooks = {
  beforeCreate: async (data, context) => {
    // Set defaults
    data.status = data.status || 'draft'
    data.createdBy = context.user?.id // a server-owned column: only a hook can set it

    // Generate slug from title
    data.slug = slugify(data.title) // your helper

    // Validate business rules (a plain Error would be a 500)
    if (data.price < 0) {
      throw createError({ statusCode: 422, message: 'Price cannot be negative' })
    }

    // Transform data
    data.name = data.name.trim().toLowerCase()

    // Return modified data
    return data
  },

  beforeUpdate: async (id, data, context) => {
    // Track who modified
    data.modifiedBy = context.user?.id
    data.modifiedAt = new Date()

    // `createdBy` / `createdAt` are already dropped from update bodies (server-owned columns)
    return data
  }
}

After Hooks (Side Effects)

After hooks are for side effects (no data modification):

export const orderHooks = {
  afterCreate: async (order, context) => {
    // Send confirmation email
    await sendEmail({
      to: order.customerEmail,
      template: 'order-confirmation',
      data: { orderNumber: order.id, total: order.total }
    })

    // Create audit log
    await context.db.insert(auditLogs).values({
      action: 'order.created',
      userId: context.user?.id,
      resourceId: order.id,
      timestamp: new Date()
    })

    // Notify external service
    await fetch('https://analytics.example.com/track', {
      method: 'POST',
      body: JSON.stringify({
        event: 'order_created',
        orderId: order.id,
        total: order.total
      })
    })
  },

  afterUpdate: async (order, context) => {
    // If status changed to 'shipped'
    if (order.status === 'shipped') {
      await sendEmail({
        to: order.customerEmail,
        template: 'order-shipped',
        data: { trackingNumber: order.trackingNumber }
      })
    }
  },

  afterDelete: async (id, context) => {
    // Clean up related records
    await context.db.delete(orderItems).where(eq(orderItems.orderId, id))

    // Log deletion
    console.log(`Order ${id} deleted by user ${context.user?.id}`)
  }
}

Hook Context

All hooks receive a HandlerContext object:

interface HandlerContext {
  db: any                    // Database instance
  schema: any                // Drizzle schema
  user: AuthUser | null      // Current user
  permissions: string[]      // User permissions
  params: Record<string, string>   // Route params
  query: Record<string, any>       // Query parameters
  validated: {               // Validated data
    body?: any
    query?: any
  }
  event: H3Event             // H3 event object
  resource: string           // Resource name (e.g., 'users')
  operation: string          // Operation type
  tenant?: {                 // Multi-tenancy info
    id: string | number
    field: string
    canAccessAllTenants: boolean
  }
  resourceConfig?: ResourceRegistration  // Resource configuration
}

Using context:

export const postHooks = {
  beforeCreate: async (data, context) => {
    // Auto-set author from authenticated user
    data.authorId = context.user.id

    // Auto-set tenant
    if (context.tenant) {
      data.organizationId = context.tenant.id
    }

    // Access query parameters
    if (context.query.publishNow === 'true') {
      data.publishedAt = new Date()
    }

    return data
  },

  afterCreate: async (post, context) => {
    // Log to database
    await context.db.insert(auditLogs).values({
      action: 'post.created',
      userId: context.user.id,
      postId: post.id
    })
  }
}

Error Handling

Before Hooks

Before hooks throw to block the operation. The hook's own error reaches the client — throw createError({ statusCode, message }) to answer with that status (a plain Error becomes a 500):

import { createError } from 'h3'
import { eq } from 'drizzle-orm'
import { users } from '../../server/database/schema'

beforeCreate: async (data, context) => {
  // Validation error - blocks creation
  if (!data.email || !data.email.includes('@')) {
    throw createError({ statusCode: 422, message: 'Invalid email address' })
  }

  // Business rule error - blocks creation
  const existingUser = await context.db.query.users.findFirst({
    where: eq(users.email, data.email)
  })

  if (existingUser) {
    throw createError({ statusCode: 409, message: 'Email already exists' })
  }

  return data
}

After Hooks

After hooks run once the write has happened. By default their errors are logged and the request still succeeds; with hookConfig.errorHandling: 'throw' the request fails with the hook's error — the write is not undone (single-record writes are not wrapped in a transaction; bulk writes in transactional mode are):

// Default behavior: log errors, don't rollback
afterCreate: async (user, context) => {
  try {
    await sendWelcomeEmail(user.email)
  } catch (error) {
    // Error is logged but operation continues
    console.error('Failed to send welcome email:', error)
  }
}

Configure error handling:

export default defineNuxtConfig({
  autoApi: {
    hookConfig: {
      // after-hooks: 'log' (default) logs and continues; 'throw' fails the request
      // before-hooks: always throw, unless set to 'log'
      errorHandling: 'log',

      // Hook timeout in milliseconds
      timeout: 5000,

      // Execute multiple hooks in parallel
      parallel: false
    }
  }
})

Hook Execution in Bulk Operations

Hooks execute for each item in bulk operations:

// Bulk create 100 users
POST /api/users/bulk { items: [/* 100 users */] }

// beforeCreate runs 100 times (once per user)
// afterCreate runs 100 times (once per user)

Common Use Cases

Audit Logging

const auditHooks = {
  afterCreate: async (record, context) => {
    await context.db.insert(auditLogs).values({
      action: `${context.resource}.created`,
      userId: context.user?.id,
      resourceId: record.id,
      changes: JSON.stringify(record)
    })
  },

  afterUpdate: async (record, context) => {
    await context.db.insert(auditLogs).values({
      action: `${context.resource}.updated`,
      userId: context.user?.id,
      resourceId: record.id,
      changes: JSON.stringify(record)
    })
  }
}

Notifications

const notificationHooks = {
  afterCreate: async (comment, context) => {
    // Notify post author about new comment
    const post = await context.db.query.posts.findFirst({
      where: eq(posts.id, comment.postId)
    })

    if (post && post.authorId !== context.user?.id) {
      await sendNotification(post.authorId, {
        type: 'new_comment',
        message: `${context.user?.name} commented on your post`
      })
    }
  }
}

Data Validation

const validationHooks = {
  beforeCreate: async (data, context) => {
    // Complex business rule validation
    if (data.price > 1000 && !context.permissions.includes('admin')) {
      throw createError({ statusCode: 403, message: 'Only admins can create items over $1000' })
    }

    // Cross-field validation
    if (data.discountPercent > 0 && !data.discountCode) {
      throw createError({ statusCode: 422, message: 'Discount code required when discount is applied' })
    }

    return data
  }
}

Cache Invalidation

const cacheHooks = {
  afterUpdate: async (record, context) => {
    await clearCache(`${context.resource}:${record.id}`)
    await clearCache(`${context.resource}:list`)
  },

  afterDelete: async (id, context) => {
    await clearCache(`${context.resource}:${id}`)
    await clearCache(`${context.resource}:list`)
  }
}

External Service Integration

const integrationHooks = {
  afterCreate: async (customer, context) => {
    // Sync to CRM
    await fetch('https://crm.example.com/api/customers', {
      method: 'POST',
      body: JSON.stringify(customer)
    })

    // Add to mailing list
    await mailchimp.lists.addMember({
      email: customer.email,
      firstName: customer.firstName,
      lastName: customer.lastName
    })
  }
}

Best Practices

  1. Keep hooks focused: One responsibility per hook
  2. Handle errors: Use try-catch in after hooks
  3. Don't query in before hooks: Data should already be validated
  4. Use after hooks for async operations: Email, webhooks, etc.
  5. Return modified data from before hooks: Always return the data object
  6. Don't modify data in after hooks: Too late, data is already saved
  7. Log important operations: Especially in production
  8. Test hooks thoroughly: They can break your API if buggy
  9. Use transactions carefully: After hooks run outside transaction scope
  10. Monitor hook performance: Set appropriate timeouts

Migration Path

If you have custom handlers, migrate to hooks:

// Before: Custom handler
export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  // Custom logic
  body.createdBy = event.context.user.id
  body.status = 'active'

  const user = await db.insert(users).values(body).returning()

  // Send email
  await sendWelcomeEmail(user.email)

  return { data: user }
})

// After: Use hooks
export const userHooks = {
  beforeCreate: async (data, context) => {
    data.createdBy = context.user?.id
    data.status = 'active'
    return data
  },

  afterCreate: async (user, context) => {
    await sendWelcomeEmail(user.email)
  }
}

Benefits:

  • Automatic validation
  • Authorization checks
  • Multi-tenancy support
  • Consistent error handling
  • Works with bulk operations

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.