Skip to main content

User synchronization

Maintain a rebuildable application user projection without duplicating Better Auth session truth.

Most applications do not need a second user table. Add one only when Convex product data needs indexed display/search fields or application-owned profile fields.

Source-of-truth rule

Better Auth user
  → trigger
  → derived application user row
  → application queries

The arrow is one-way for copied auth fields. The projection must be rebuildable from Better Auth.

Define the projection

convex/schema.ts
users: defineTable({
  authUserId: v.string(),
  name: v.optional(v.string()),
  image: v.optional(v.string()),
  createdAt: v.number(),
  updatedAt: v.number(),
}).index('by_auth_user_id', ['authUserId'])

Create projection triggers

convex/auth.ts
import {
  createUserProjectionTriggers,
  type BetterAuthUserProjectionSource,
} from '@lupinum/better-convex-nuxt/better-auth/server'

const userProjection = createUserProjectionTriggers<BetterAuthUserProjectionSource, Doc<'users'>>({
  table: 'users',
  index: 'by_auth_user_id',
  authIdField: 'authUserId',
  createDoc: ({ user, now }) => ({
    name: user.name ?? undefined,
    image: user.image ?? undefined,
    createdAt: now,
    updatedAt: now,
  }),
  patchDoc: ({ user, now }) => ({
    name: user.name ?? undefined,
    image: user.image ?? undefined,
    updatedAt: now,
  }),
  rebuildDoc: ({ user, now }) => ({
    name: user.name ?? undefined,
    image: user.image ?? undefined,
    updatedAt: now,
  }),
})

The helper sets authIdField: it injects the Better Auth user ID on insert and prevents create, update, or rebuild callbacks from replacing it. Callbacks set only the application projection fields.

now is captured once when each helper handler starts. It is suitable for projection bookkeeping such as updatedAt. When an update finds no projection, that update is the inserting event. createDoc receives the current update snapshot and that handler's now. Copy the Better Auth createdAt value from user when the projection must retain the auth user's original creation time.

Pass the handlers to createBetterConvexAuth as user triggers, and export the trigger functions:

convex/auth.ts
const authFunctions: AuthFunctions = internal.auth

export const auth = createBetterConvexAuth<DataModel>(components.betterAuth, {
  authFunctions,
  triggers: {
    user: {
      onCreate: async (ctx, user) =>
        userProjection.user.onCreate(ctx, user as BetterAuthUserProjectionSource),
      onUpdate: async (ctx, user, previousUser) =>
        userProjection.user.onUpdate(
          ctx,
          user as BetterAuthUserProjectionSource,
          previousUser as BetterAuthUserProjectionSource,
        ),
      onDelete: async (ctx, user) =>
        userProjection.user.onDelete(ctx, user as BetterAuthUserProjectionSource),
    },
  },
})

export const { onCreate, onUpdate, onDelete } = auth.triggerFunctions()

Import AuthFunctions from @lupinum/better-convex-nuxt/better-auth/server and internal from ./_generated/api. starters/team/convex/auth.ts is a complete example.

Rebuild and repair

The helper exposes user.rebuild(ctx, users) for one bounded page of Better Auth users. Run it from an internal function that only operators can call:

  1. Page through Better Auth users.
  2. Call rebuild for each page.
  3. Repeat until the Better Auth query reports isDone.
  4. Record inserted, patched, and skipped counts.

Create, update, and rebuild read at most two rows for each auth ID. Zero or one row is valid. Two rows are ambiguous, so the helper throws a ConvexError whose data is exactly { code: 'AUTH_USER_PROJECTION_CONFLICT' } before it runs a projection callback or writes that user's projection. The public auth HTTP boundary converts this internal failure to its existing generic failure.

The helper does not guess which duplicate is correct and does not repair duplicates. Your application must repair them, because only your fields show which row is correct. The delete trigger is deliberately different: after the Better Auth user is deleted, it deletes every projection row matched by that auth ID in the same Convex transaction. If that exceeds Convex's transaction limits, the deletion fails atomically; the helper creates no repair state.

The helper does not schedule rebuilds and does not decide who may run them.

Do not expose the conflict code or projection values through a public route, log, analytics event, or client error. createDoc, patchDoc, and rebuildDoc run inside the calling Convex mutation and must not call external services.

Failure behavior

An update recreates a missing projection from the current user snapshot. This repairs drift from adoption, migration, or application-owned deletion. A later create sees that row and is idempotent.

Do not use the projection for authentication or as the sole source of revocable authorization.