BETTER-CONVEX

Triggers

Lifecycle hooks for user and session events.

In this guide, we'll explore auth triggers in better-convex. You'll learn to run custom logic when auth data changes, including creating related records on signup, cleaning up on deletion, and validating data before writes.

Overview

Triggers let you run custom logic when auth data changes:

Use CaseDescription
Create related recordsWhen users sign up
Clean up dataWhen users are deleted
Validate or transformBefore writes
Sync external servicesAfter changes

Let's explore the available triggers.

Available Triggers

Before Triggers

Run inside the same Convex transaction before the database write. Can modify data, enforce invariants, or reject the operation.

TriggerDescriptionReturn
beforeCreateBefore inserting a new recordModified data or undefined
beforeUpdateBefore updating a recordModified update or undefined
beforeDeleteBefore deleting a recordModified doc or undefined

After Triggers

Run after the database write. Use for side effects.

TriggerDescriptionReturn
onCreateAfter a record is createdvoid
onUpdateAfter a record is updatedvoid
onDeleteAfter a record is deletedvoid

Setup

Pass triggers to createClient:

convex/functions/auth.ts
import { createClient } from 'better-convex/auth';

export const authClient = createClient({
  authFunctions: internal.auth,
  schema,
  triggers: {
    user: {
      beforeCreate: async (ctx, data) => { /* ... */ },
      onCreate: async (ctx, doc) => { /* ... */ },
      beforeUpdate: async (ctx, doc, update) => { /* ... */ },
      onUpdate: async (ctx, newDoc, oldDoc) => { /* ... */ },
      beforeDelete: async (ctx, doc) => { /* ... */ },
      onDelete: async (ctx, doc) => { /* ... */ },
    },
    session: {
      onCreate: async (ctx, session) => { /* ... */ },
    },
  },
});

User Triggers

beforeCreate

Transform user data before insertion:

triggers: {
  user: {
    beforeCreate: async (ctx, data) => {
      // Generate unique username
      const username = await generateUniqueUsername(ctx, data.name);

      // Assign admin role based on email
      const role = adminEmails.includes(data.email) ? 'admin' : 'user';

      return {
        ...data,
        username,
        role,
      };
    },
  },
}

onCreate

Create related records after user signup:

triggers: {
  user: {
    onCreate: async (ctx, user) => {
      // Create default profile
      await ctx.db.insert('profiles', {
        userId: user._id,
        bio: '',
        avatar: user.image,
      });

      // Create personal organization
      await ctx.db.insert('organizations', {
        name: `${user.name}'s Workspace`,
        ownerId: user._id,
        type: 'personal',
      });

      // Schedule welcome email
      await ctx.scheduler.runAfter(0, internal.emails.sendWelcome, {
        userId: user._id,
        email: user.email,
      });
    },
  },
}
import { entsTableFactory } from 'convex-ents';
import { entDefinitions } from '../lib/ents';

triggers: {
  user: {
    onCreate: async (ctx, user) => {
      const table = entsTableFactory(ctx, entDefinitions);

      // Create default profile
      await table('profiles').insert({
        userId: user._id,
        bio: '',
        avatar: user.image,
      });

      // Create personal organization
      await table('organizations').insert({
        name: `${user.name}'s Workspace`,
        ownerId: user._id,
        type: 'personal',
      });

      // Schedule welcome email
      await ctx.scheduler.runAfter(0, internal.emails.sendWelcome, {
        userId: user._id,
        email: user.email,
      });
    },
  },
}

onUpdate

Sync changes to related records:

triggers: {
  user: {
    onUpdate: async (ctx, newDoc, oldDoc) => {
      // Sync avatar change to profiles
      if (newDoc.image !== oldDoc.image) {
        const profile = await ctx.db
          .query('profiles')
          .withIndex('by_user', (q) => q.eq('userId', newDoc._id))
          .first();

        if (profile) {
          await ctx.db.patch(profile._id, { avatar: newDoc.image });
        }
      }
    },
  },
}
import { entsTableFactory } from 'convex-ents';
import { entDefinitions } from '../lib/ents';

triggers: {
  user: {
    onUpdate: async (ctx, newDoc, oldDoc) => {
      const table = entsTableFactory(ctx, entDefinitions);

      // Sync avatar change to profiles
      if (newDoc.image !== oldDoc.image) {
        const profile = await table('profiles').get('by_user', newDoc._id);

        if (profile) {
          await profile.patch({ avatar: newDoc.image });
        }
      }
    },
  },
}

onDelete

Clean up related data:

Cascade deletes in triggers are permanent. Test thoroughly and consider soft deletes for critical data.

triggers: {
  user: {
    onDelete: async (ctx, user) => {
      // Delete user's profiles
      const profiles = await ctx.db
        .query('profiles')
        .withIndex('by_user', (q) => q.eq('userId', user._id))
        .collect();

      for (const profile of profiles) {
        await ctx.db.delete(profile._id);
      }

      // Delete user's organizations
      const orgs = await ctx.db
        .query('organizations')
        .withIndex('by_owner', (q) => q.eq('ownerId', user._id))
        .collect();

      for (const org of orgs) {
        await ctx.db.delete(org._id);
      }
    },
  },
}
import { entsTableFactory } from 'convex-ents';
import { entDefinitions } from '../lib/ents';

triggers: {
  user: {
    onDelete: async (ctx, user) => {
      const table = entsTableFactory(ctx, entDefinitions);

      // Delete user's profiles
      const profiles = await table('profiles', 'by_user', (q) =>
        q.eq('userId', user._id)
      );

      for (const profile of profiles) {
        await profile.delete();
      }

      // Delete user's organizations
      const orgs = await table('organizations', 'by_owner', (q) =>
        q.eq('ownerId', user._id)
      );

      for (const org of orgs) {
        await org.delete();
      }
    },
  },
}

Session Triggers

onCreate

Set default session state:

triggers: {
  session: {
    onCreate: async (ctx, session) => {
      // Set active organization from user's last active
      if (!session.activeOrganizationId) {
        const user = await ctx.db.get(session.userId);

        if (user?.lastActiveOrganizationId) {
          await ctx.db.patch(session._id, {
            activeOrganizationId: user.lastActiveOrganizationId,
          });
        }
      }
    },
  },
}
import { entsTableFactory } from 'convex-ents';
import { entDefinitions } from '../lib/ents';

triggers: {
  session: {
    onCreate: async (ctx, session) => {
      const table = entsTableFactory(ctx, entDefinitions);

      // Set active organization from user's last active
      if (!session.activeOrganizationId) {
        const user = await table('user').get(session.userId);

        if (user?.lastActiveOrganizationId) {
          await table('session').getX(session._id).patch({
            activeOrganizationId: user.lastActiveOrganizationId,
          });
        }
      }
    },
  },
}

Export Trigger Functions

After configuring triggers, export them from your auth file:

convex/functions/auth.ts
// Export trigger functions for Better Auth adapter
export const {
  beforeCreate,
  beforeDelete,
  beforeUpdate,
  onCreate,
  onDelete,
  onUpdate,
} = authClient.triggersApi();

These functions are called by the Better Auth adapter during CRUD operations.

Type Safety

Triggers are fully typed based on your schema:

triggers: {
  user: {
    // `data` is typed as Infer<Schema['tables']['user']['validator']>
    beforeCreate: async (ctx, data) => {
      data.email; // string
      data.name;  // string | null
      return data;
    },

    // `doc` includes _id and system fields
    onCreate: async (ctx, doc) => {
      doc._id;           // Id<'user'>
      doc._creationTime; // number
      doc.email;         // string
    },

    // `update` is Partial of the validator
    beforeUpdate: async (ctx, doc, update) => {
      update.name;  // string | null | undefined
      update.email; // string | undefined
      return update;
    },
  },
}

Next Steps

On this page