Better Auth Convex
Local installation of Better Auth directly in your Convex app schema, with direct database access instead of component-based queries.
Why Better Auth Convex?
The official @convex-dev/better-auth component stores auth tables in a component schema. This package provides an alternative approach with direct schema integration.
This package provides direct local installation:
- Auth tables live in your app schema - Not in a component boundary
- Direct database access - No
ctx.runQuery/ctx.runMutation overhead (>50ms latency that increases with app size)
- Unified context - Auth triggers can directly access and modify your app tables transactionally
- Full TypeScript inference - Single schema, single source of truth
[!WARNING]
BREAKING CHANGE: Auth tables are stored in your app schema instead of the component schema. If you're already in production with @convex-dev/better-auth, you'll need to write a migration script to move your auth data.
Prerequisites
- Follow the official Better Auth + Convex setup guide first
- Choose your framework guide
- IGNORE these steps from the framework guide:
- Step 2: "Register the component" - We don't use the component approach
- Step 5:
convex/auth.ts - We'll use a different setup
- Step 7:
convex/http.ts - We use different route registration
- Then come back here to install locally
Installation
pnpm add better-auth@1.3.34 better-auth-convex
Local Setup
You'll need convex/auth.config.ts and update your files to install Better Auth directly in your app:
import { betterAuth } from 'better-auth';
import { convex } from '@convex-dev/better-auth/plugins';
import { admin, organization } from 'better-auth/plugins';
import {
type AuthFunctions,
createClient,
createApi,
} from 'better-auth-convex';
import { internal } from './_generated/api';
import type { MutationCtx, QueryCtx, GenericCtx } from './_generated/server';
import type { DataModel } from './_generated/dataModel';
import schema from './schema';
const authFunctions: AuthFunctions = internal.auth;
export const authClient = createClient<DataModel, typeof schema>({
authFunctions,
schema,
triggers: {
user: {
beforeCreate: async (_ctx, data) => {
const username =
data.username?.trim() ||
data.email?.split('@')[0] ||
`user-${Date.now()}`;
return {
...data,
username,
};
},
onCreate: async (ctx, user) => {
const orgId = await ctx.db.insert('organization', {
name: `${user.name}'s Workspace`,
slug: `personal-${user._id}`,
});
await ctx.db.patch(user._id, {
personalOrganizationId: orgId,
});
},
beforeDelete: async (ctx, user) => {
if (user.personalOrganizationId) {
await ctx.db.delete(user.personalOrganizationId);
}
return user;
},
},
session: {
onCreate: async (ctx, session) => {
if (!session.activeOrganizationId) {
const user = await ctx.db.get(session.userId);
if (user?.personalOrganizationId) {
await ctx.db.patch(session._id, {
activeOrganizationId: user.personalOrganizationId,
});
}
}
},
},
},
});
export const createAuth = (
ctx: GenericCtx,
{ optionsOnly } = { optionsOnly: false }
) => {
const baseURL = process.env.NEXT_PUBLIC_SITE_URL!;
return betterAuth({
baseURL,
logger: { disabled: optionsOnly },
plugins: [
convex(),
admin(),
organization({
}),
],
session: {
expiresIn: 60 * 60 * 24 * 30,
updateAge: 60 * 60 * 24 * 15,
},
database: authClient.httpAdapter(ctx),
});
};
export const auth = createAuth({} as any, { optionsOnly: true });
export const getAuth = <Ctx extends QueryCtx | MutationCtx>(ctx: Ctx) => {
return betterAuth({
...auth.options,
database: authClient.adapter(ctx, auth.options),
});
};
export const {
beforeCreate,
beforeDelete,
beforeUpdate,
onCreate,
onDelete,
onUpdate,
} = authClient.triggersApi();
export const {
create,
deleteMany,
deleteOne,
findMany,
findOne,
updateMany,
updateOne,
} = createApi(schema, {
...auth.options,
skipValidation: true,
});
The trigger API exposes both before* and on* hooks. The before variants run inside the same Convex transaction just ahead of the database write, letting you normalize input, enforce invariants, or perform cleanup and return any transformed payload that should be persisted.
import { httpRouter } from 'convex/server';
import { registerRoutes } from 'better-auth-convex';
import { createAuth } from './auth';
const http = httpRouter();
registerRoutes(http, createAuth);
export default http;
Key Concepts
Direct DB Access vs HTTP Adapter
export const someQuery = query({
handler: async (ctx) => {
const auth = getAuth(ctx);
const user = await auth.api.getUser({ userId });
},
});
export const someAction = action({
handler: async (ctx) => {
const auth = createAuth(ctx);
},
});
Unified Schema Benefits
Helper Functions
All helpers are exported from the main package:
import { getAuthUserId, getSession, getHeaders } from 'better-auth-convex';
const userId = await getAuthUserId(ctx);
const session = await getSession(ctx);
const headers = await getHeaders(ctx);
API Options
skipValidation
The createApi function accepts a skipValidation option that uses generic validators instead of typed validators:
export const { create, ... } = createApi(schema, {
...auth.options,
skipValidation: true,
});
When to use: Enable this option to significantly reduce generated type sizes. Since these are internal functions only called by the auth adapter, input validation is optional. The trade-off is less precise TypeScript inference for the internal API arguments.
Custom Mutation Builders
Both createClient and createApi accept an optional internalMutation parameter, allowing you to wrap internal mutations with custom context or behavior.
Use Cases
This is useful when you need to:
- Wrap database operations with custom context (e.g., triggers, logging)
- Apply middleware to all auth mutations
- Inject dependencies or configuration
Example with Triggers
import { customMutation, customCtx } from 'convex-helpers/server/customFunctions';
import { internalMutationGeneric } from 'convex/server';
import { registerTriggers } from '@convex/triggers';
const triggers = registerTriggers();
const internalMutation = customMutation(
internalMutationGeneric,
customCtx(async (ctx) => ({
db: triggers.wrapDB(ctx).db,
}))
);
export const authClient = createClient<DataModel, typeof schema>({
authFunctions,
schema,
internalMutation,
triggers: { ... }
});
export const { create, updateOne, ... } = createApi(schema, {
...auth.options,
internalMutation,
});
This ensures all auth operations (CRUD + triggers) use your wrapped database context.
Updating the Schema
Better Auth configuration changes may require schema updates. The Better Auth docs will often note when this is the case. To regenerate the schema (it's generally safe to do), run:
cd convex && npx @better-auth/cli generate -y --output authSchema.ts
Import Generated Schema (Recommended)
Import the generated schema in your convex/schema.ts:
import { authSchema } from './authSchema';
import { defineSchema } from 'convex/server';
export default defineSchema({
...authSchema,
});
Or Use as Reference
Alternatively, use the generated schema as a reference to manually update your existing schema:
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
export default defineSchema({
user: defineTable({
twoFactorEnabled: v.optional(v.union(v.null(), v.boolean())),
}).index('email_name', ['email', 'name']),
});
Adding Custom Indexes
Better Auth may log warnings about missing indexes for certain queries. You can add custom indexes by extending the generated schema:
import { authSchema } from './authSchema';
import { defineSchema } from 'convex/server';
export default defineSchema({
...authSchema,
user: authSchema.user.index('username', ['username']),
});
Note: authSchema table names and field names should not be customized directly. Use Better Auth configuration options to customize the schema, then regenerate to see the expected structure.
Credits
Built on top of Better Auth and @convex-dev/better-auth, optimized for Convex.