Convex + Better Auth

Component user id in app table

Migrate user id reference from component to app table

This guide shows one of two recommended strategies for migrating away from maintaining a user.userId field in the Better Auth user table. Read the overview for more information.

The migration guide covers basic required changes, but your application may have additional or altered requirements depending on how the Better Auth component is implemented. Be sure to review any auth related code for potential impacts. Avoid type assertions and checking for type errors is encouraged.

This guide is for migrating from tracking the app user id in the component to tracking the component user id in the app user table via an authId field.

Run convex dev

Keep the Convex dev server running while following the migration steps.

npx convex dev

Install migrations component

Install and configure the migrations component.

npm install @convex-dev/migrations
convex/convex.config.ts
import { defineApp } from "convex/server";
import betterAuth from "@convex-dev/better-auth/convex.config";
import migrations from "@convex-dev/migrations/convex.config"; 

const app = defineApp();
app.use(betterAuth);
app.use(migrations); 

export default app;

Add authId field to users table

Add an authId field to your app users table. You can use any name you want, but keep in mind that this guide will refer to it as authId. The field should be optional for now, it can be changed to required later.

convex/schema.ts
export default defineSchema({
  users: defineTable({
    email: v.string(),
    authId: v.optional(v.string()), 
  })
    .index("email", ["email"])
    .index("authId", ["authId"]), 
  // ...
});

Add migration functions

Create a convex/migrations.ts file and add migration functions. Two functions are added - one for adding the authId field to the app users table, and one for removing the userId field from the related Better Auth user. We'll run them in later steps.

convex/migrations.ts
import { Migrations } from "@convex-dev/migrations";
import { components, internal } from "./_generated/api";
import type { DataModel } from "./_generated/dataModel";
import { authComponent } from "./auth";

export const migrations = new Migrations<DataModel>(components.migrations);

// Define migration functions
export const migrationAddAuthId = migrations.define({
  table: "users",
  migrateOne: async (ctx, user) => {
    // For each user in the app users table, get the related Better Auth user id
    // and set it to the `authId` field.
    if (user.authId === undefined) {
      const authUser = await authComponent.migrationGetUser(ctx, user._id);
      if (!authUser) {
        throw new Error(`Auth user not found for id ${user._id}`);
      }
      await ctx.db.patch(user._id, { authId: authUser._id });
    }
  },
});

export const migrationRemoveUserId = migrations.define({
  table: "users",
  migrateOne: async (ctx, user) => {
    // For each user in the app users table, remove the `userId` value from the
    // related Better Auth user
    await authComponent.migrationRemoveUserId(ctx, user._id);
  },
});

// Export runnable migration functions
export const addAuthId = migrations.runner(
  internal.migrations.migrationAddAuthId
);

export const removeUserId = migrations.runner(
  internal.migrations.migrationRemoveUserId
);

Update adapter exports (Local Install only)

If using Local Install, update the adapter exports to include the migrationRemoveUserId function.

convex/betterAuth/adapter.ts
export const {
  create,
  findOne,
  findMany,
  updateOne,
  updateMany,
  deleteOne,
  deleteMany,
  migrationRemoveUserId, 
} = createApi(schema, createAuth);

Update user triggers

Update user triggers to set the user authId field on creation, and use it to get the app user on update and delete.

Depending on how you're using Better Auth, you may or may not be using all of these triggers.

convex/auth.ts
export const authComponent = createClient<DataModel, typeof betterAuthSchema>(
  components.betterAuth,
  {
    authFunctions,
    triggers: {
      user: {
        onCreate: async (ctx, authUser) => {
          const userId = await ctx.db.insert("users", {
            email: authUser.email,
            authId: authUser._id, 
          });
          await authComponent.setUserId(ctx, authUser._id, userId);
        },
      onUpdate: async (ctx, authUser, prevAuthUser) => {
        // Example trigger logic for syncing email address, your logic
        // and details may differ
        if (authUser.email !== prevAuthUser.email) {
          const user = await ctx.db.query("users")
            .withIndex("authId", (q) => q.eq("authId", authUser._id))
            .unique();
          if (!user) {
            throw new ConvexError('User not found')
          }
          await ctx.db.patch(authUser.authId as Id<'users'>, { 
          await ctx.db.patch(user._id, { 
            email: authUser.email,
          })
        }
      },
      onDelete: async (ctx, authUser) => {
        // Example logic, your logic and details may differ
        await ctx.db.delete(authUser.userId as Id<'users'>) 
        const user = await ctx.db.query("users")
          .withIndex("authId", (q) => q.eq("authId", authUser._id))
          .unique();
        if (!user) {
          throw new ConvexError('User not found')
        }
        await ctx.db.delete(authUser.userId as Id<'users'>) 
        await ctx.db.delete(user._id) 
      },
        // ...
      },
    },
  }
);

Run addAuthId migration

At this point, with the convex dev server running, your code is still functioning as it did prior to starting this guide, but is now also defining authId on every new user, so all new users moving forward will have an authId value.

This migration will backfill the authId field for previously existing users in your development deployment.

npx convex run migrations:addAuthId

Deploy to production

Before proceeding, make sure the migration was successful in your development deployment by running your application locally. Also confirm that the authId field on all users in your development deployment app user table is set.

The changes made so far, which are strictly additive and not destructive, should be deployed to production. The first migration will need to run there as well.

Deployment steps depend on how you deploy your app. Most apps deploy through Netlify or Vercel by merging changes to the main branch of their project repo.

Run addAuthId migration in production

With production successfully deployed, the migration can be run against the production deployment. This can happen through the production deployment dashboard, or through the cli using the --prod flag.

npx convex run migrations:addAuthId --prod

Make authId required

Now that all authId values are set, make the authId field required in the app user table. If the convex dev server errors after this change, you probably have one or more users in the app user table that don't have an authId value. This should only be possible for users that do not have a related Better Auth user.

convex/schema.ts
export default defineSchema({
  users: defineTable({
    email: v.string(),
    authId: v.optional(v.string()), 
    authId: v.string(), 
  }).index("email", ["email"]),
});

Remove userId writes

Update the user onCreate trigger to stop setting the userId field on the Better Auth user.

convex/auth.ts
export const authComponent = createClient<DataModel>(components.betterAuth, {
  authFunctions,
  triggers: {
    user: {
      onCreate: async (ctx, authUser) => {
        await ctx.db.insert("users", {
          email: authUser.email,
          authId: authUser._id,
        });
        await authComponent.setUserId(ctx, authUser._id, userId); 
      },
    },
  },
});

Update userId references

Any references in your codebase to the userId field from the Better Auth user should be updated to reference the authId field in the app user table. Your app may not have any such references.

convex/auth.ts
export const getCurrentUser = query({
  args: {},
  handler: async (ctx) => {
    const authUser = await authComponent.safeGetAuthUser(ctx);
    if (!authUser) {
      return;
    }
    const user = await ctx.db.get(authUser.userId as Id<"users">); 
    const user = await ctx.db
      .query("users")
      .withIndex("authId", (q) => q.eq("authId", authUser._id))
      .unique();
    if (!user) {
      return;
    }
    return { ...user, ...withoutSystemFields(authUser) };
  },
});

Run removeUserId migration

The migration is now functionally complete, all that remains are unused userId values in the Better Auth user table.

Run the removeUserId migration to clear the unused values.

npx convex run migrations:removeUserId

Deploy to production

After ensuring your application works after these changes, and that the userId field is no longer being set on the Better Auth user table, changes should be deployed to production.

Run removeUserId migration in production

Once deployed, as we did previously, the second migration should also be run in production.

npx convex run migrations:removeUserId --prod

Remove migration code

Remove the migration dependencies and code added previously.

Only remove the migration component if not used elsewhere in your project.

npm uninstall @convex-dev/migrations
convex/convex.config.ts
import { defineApp } from "convex/server";
import betterAuth from "@convex-dev/better-auth/convex.config";
import migrations from "@convex-dev/migrations/convex.config"; 

const app = defineApp();
app.use(betterAuth);
app.use(migrations); 

export default app;
convex/migrations.ts
import { Migrations } from "@convex-dev/migrations";
import { components, internal } from "./_generated/api";
import type { DataModel } from "./_generated/dataModel";
import { authComponent } from "./auth";

export const migrations = new Migrations<DataModel>(components.migrations);

// Define migration functions
export const migrationAddAuthId = migrations.define({
  table: "users",
  migrateOne: async (ctx, user) => {
    // For each user in the app users table, get the related Better Auth user id
    // and set it to the `authId` field.
    if (user.authId === undefined) {
      const authUser = await authComponent.migrationGetUser(ctx, user._id);
      if (!authUser) {
        throw new Error(`Auth user not found for id ${user._id}`);
      }
      await ctx.db.patch(user._id, { authId: authUser._id });
    }
  },
});

export const migrationRemoveUserId = migrations.define({
  table: "users",
  migrateOne: async (ctx, user) => {
    // For each user in the app users table, remove the `userId` value from the
    // related Better Auth user
    await authComponent.migrationRemoveUserId(ctx, user._id);
  },
});

// Export runnable migration functions
export const addAuthId = migrations.runner(
  internal.migrations.migrationAddAuthId
);

export const removeUserId = migrations.runner(
  internal.migrations.migrationRemoveUserId
);
This file only exists if using Local Install.
convex/betterAuth/adapter.ts
export const {
  create,
  findOne,
  findMany,
  updateOne,
  updateMany,
  deleteOne,
  deleteMany,
  migrationRemoveUserId, 
} = createApi(schema, createAuth);

Migration complete 🎉

You've successfully migrated your user table foreign key from Better Auth into your app user table.