From Better Auth
Migrate from @convex-dev/better-auth to better-convex.
In this guide, we'll migrate from @convex-dev/better-auth (the component-based auth package) to better-convex. You'll remove the component pattern, update imports, and configure the new trigger system.
Overview
The main differences between the packages:
| Aspect | @convex-dev/better-auth | better-convex |
|---|---|---|
| Architecture | Convex component pattern | Direct integration |
| Triggers | Not built-in | triggers: { user, session } |
| Client Creation | createClient(components.betterAuth, {...}) | createClient({ authFunctions, schema }) |
| DB Adapter | authComponent.adapter(ctx) | authClient.adapter(ctx, getAuthOptions) |
| Provider | ConvexBetterAuthProvider | ConvexAuthProvider |
| React Utils | AuthBoundary | createAuthMutations(), auth store |
Let's migrate step by step.
Step 1: Update Dependencies
Install better-convex alongside the existing package (@convex-dev/better-auth remains a peer dependency).
bun add better-convexStep 2: Remove Component Pattern
Next, we'll remove the Convex component configuration. This is the biggest architectural change.
Production only: Run component-data migration only if this app already has production data in _components/betterAuth.
Production only: Migrate component data to app namespace
If you are not migrating existing production data, skip this sub-step and continue with file deletion below.
Warning: This ID remap flow is validated for the latest Convex document ID format. Older historical ID formats may exist and are not guaranteed by this script.
Test locally first: Run this migration end-to-end against a local/dev copy before touching production. Convex internals can change over time, which can invalidate this migration.
IMPORTANT: Importing with --replace is destructive and replaces all data in the target deployment.
Prerequisite
- Push/deploy your new app schema first so destination auth tables exist in app namespace.
- If those tables do not exist yet, migration/import fails with missing table ID errors.
1) Export a backup zip
npx convex export --path ./snapshot.zip2) Download migration utility files
Create a local folder and download the migration utility files:
mkdir -p ./better-auth-component-migration
curl -fsSL https://raw.githubusercontent.com/udecode/better-convex/main/tooling/migrations/better-auth-component/cli.ts -o ./better-auth-component-migration/cli.ts
curl -fsSL https://raw.githubusercontent.com/udecode/better-convex/main/tooling/migrations/better-auth-component/id-v6.ts -o ./better-auth-component-migration/id-v6.ts
curl -fsSL https://raw.githubusercontent.com/udecode/better-convex/main/tooling/migrations/better-auth-component/migrate-snapshot.ts -o ./better-auth-component-migration/migrate-snapshot.ts
curl -fsSL https://raw.githubusercontent.com/udecode/better-convex/main/tooling/migrations/better-auth-component/zip.ts -o ./better-auth-component-migration/zip.ts3) Install migration zip dependencies
npm i -D yazl yauzl4) Run the migration utility (creates a new migration.zip)
node --experimental-strip-types ./better-auth-component-migration/cli.ts \
--snapshotZip ./snapshot.zip \
--outputZip ./migration.zipOptional: migrate only specific tables:
node --experimental-strip-types ./better-auth-component-migration/cli.ts \
--snapshotZip ./snapshot.zip \
--outputZip ./migration.zip \
--filters=user,jwksOptional: set a custom temp working directory:
node --experimental-strip-types ./better-auth-component-migration/cli.ts \
--snapshotZip ./snapshot.zip \
--outputZip ./migration.zip \
--workDir ./snapshot.temp5) Import manually
npx convex import ./migration.zip --replace -yFiles to delete:
convex/betterAuth/folder (entire directory)- Remove
app.use(betterAuth)fromconvex/convex.config.ts
import { defineApp } from "convex/server";
import betterAuth from "./betterAuth/convex.config";
import resend from "@convex-dev/resend/convex.config";
const app = defineApp();
app.use(betterAuth); // Remove this line
app.use(resend);
export default app;import { defineApp } from "convex/server";
import resend from "@convex-dev/resend/convex.config";
const app = defineApp();
app.use(resend); // Keep other components
export default app;Step 3: Update auth.config.ts
The auth config stays similar. We just need to ensure we're passing the static JWKS.
import { getAuthConfigProvider } from "@convex-dev/better-auth/auth-config";
import type { AuthConfig } from "convex/server";
export default {
providers: [getAuthConfigProvider()],
} satisfies AuthConfig;import { getAuthConfigProvider } from "@convex-dev/better-auth/auth-config";
import type { AuthConfig } from "convex/server";
export default {
providers: [getAuthConfigProvider({ jwks: process.env.JWKS })],
} satisfies AuthConfig;Passing a static JWKS avoids database queries during token verification. Run npx better-convex env sync --auth to generate it.
Step 4: Migrate auth.ts
This is the main migration. We'll replace the component-based client with direct integration.
import { components } from "./_generated/api";
import { createClient, GenericCtx } from "@convex-dev/better-auth";
import { convex } from "@convex-dev/better-auth/plugins";
import authSchema from "./betterAuth/schema";
import authConfig from "./auth.config";
import { betterAuth, type BetterAuthOptions } from "better-auth/minimal";
export const authComponent = createClient<DataModel, typeof authSchema>(
components.betterAuth,
{ local: { schema: authSchema }, verbose: false }
);
export const getAuthOptions = (ctx: GenericCtx<DataModel>) => ({
baseURL: process.env.SITE_URL,
database: authComponent.adapter(ctx),
// ... plugins and options
});
export const getAuth = (ctx: GenericCtx<DataModel>) =>
betterAuth(getAuthOptions(ctx));
export const { getAuthUser } = authComponent.clientApi();
export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
return authComponent.safeGetAuthUser(ctx);
},
});import { convex } from "@convex-dev/better-auth/plugins";
import { betterAuth, type BetterAuthOptions } from "better-auth";
import { createApi, createClient, type AuthFunctions } from "better-convex/auth";
import { internal } from "./_generated/api";
import type { DataModel } from "./_generated/dataModel";
import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
import { withOrm } from "../lib/orm";
import authConfig from "./auth.config";
import schema from "./schema";
type GenericCtx = QueryCtx | MutationCtx | ActionCtx;
// Reference internal auth functions (generated after first `npx convex dev`)
const authFunctions: AuthFunctions = internal.auth;
// Create the auth client with triggers
export const authClient = createClient<DataModel, typeof schema>({
authFunctions,
schema,
context: withOrm, // Optional: enrich auth mutation + trigger ctx once
triggers: {
user: {
beforeCreate: async (_ctx, data) => {
// Transform user data before creation
return data;
},
onCreate: async (ctx, user) => {
// Side effects after user creation
console.log("User created:", user.id);
},
},
session: {
onCreate: async (ctx, session) => {
// Side effects after session creation
},
},
},
});
// Auth options factory
const getAuthOptions = (ctx: GenericCtx) =>
({
baseURL: process.env.SITE_URL!,
emailAndPassword: { enabled: true },
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
plugins: [
convex({
authConfig,
jwks: process.env.JWKS,
}),
],
database: authClient.adapter(ctx, getAuthOptions),
}) satisfies BetterAuthOptions;
// Single context-aware auth entrypoint
export const getAuth = (ctx: GenericCtx) =>
betterAuth(getAuthOptions(ctx));
// Generate internal CRUD functions
export const {
create,
deleteMany,
deleteOne,
findMany,
findOne,
updateMany,
updateOne,
getLatestJwks,
rotateKeys,
} = createApi(schema, getAuth, {
context: withOrm, // Optional: same context enrichment
});
// Generate trigger handlers
export const {
beforeCreate,
beforeDelete,
beforeUpdate,
onCreate,
onDelete,
onUpdate,
} = authClient.triggersApi();
// Export for Better Auth CLI
// biome-ignore lint/suspicious/noExplicitAny: Required for CLI
export const auth = betterAuth(getAuthOptions({} as any));Key changes:
| Before | After |
|---|---|
createClient(components.betterAuth, {...}) | createClient({ authFunctions, schema, triggers }) |
| ORM context setup | context |
authComponent.adapter(ctx) | authClient.adapter(ctx, getAuthOptions) |
authComponent.clientApi() | createApi() generates internal functions |
| No triggers | Built-in triggers: { user, session } |
Context-aware adapter: authClient.adapter(ctx, getAuthOptions) automatically picks direct DB access in queries/mutations and HTTP adapter behavior in actions/HTTP contexts.
Step 5: Add Polyfills
Convex's runtime environment doesn't include MessageChannel, which Better Auth's HTTP handling requires. Create this polyfill file:
// polyfill MessageChannel without using node:events
if (typeof MessageChannel === 'undefined') {
class MockMessagePort {
onmessage: ((ev: MessageEvent) => void) | undefined;
onmessageerror: ((ev: MessageEvent) => void) | undefined;
addEventListener() {}
close() {}
dispatchEvent(_event: Event): boolean {
return false;
}
postMessage(_message: unknown, _transfer: Transferable[] = []) {}
removeEventListener() {}
start() {}
}
class MockMessageChannel {
port1: MockMessagePort;
port2: MockMessagePort;
constructor() {
this.port1 = new MockMessagePort();
this.port2 = new MockMessagePort();
}
}
globalThis.MessageChannel =
MockMessageChannel as unknown as typeof MessageChannel;
}Import this at the top of your HTTP file (before other imports).
Step 6: Update http.ts
If you're not using cRPC, keep Convex's built-in httpRouter and use registerRoutes.
Use Hono only when you need cRPC or Hono middleware.
import { httpRouter } from 'convex/server';
import { authComponent, getAuth } from './auth';
const http = httpRouter();
authComponent.registerRoutes(http, getAuth);
export default http;Update the HTTP router:
import '../lib/http-polyfills';
import { registerRoutes } from 'better-convex/auth';
import { httpRouter } from 'convex/server';
import { getAuth } from './auth';
const http = httpRouter();
registerRoutes(http, getAuth, {
cors: {
allowedOrigins: [process.env.SITE_URL!],
},
});
export default http;import '../lib/http-polyfills';
import { authMiddleware } from 'better-convex/auth';
import { HttpRouterWithHono } from 'better-convex/server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { getAuth } from './auth';
const app = new Hono();
// CORS for API routes
app.use(
'/api/*',
cors({
origin: process.env.SITE_URL!,
allowHeaders: ['Content-Type', 'Authorization', 'Better-Auth-Cookie'],
exposeHeaders: ['Set-Better-Auth-Cookie'],
credentials: true,
}),
);
// Better Auth middleware
app.use(authMiddleware(getAuth));
export default new HttpRouterWithHono(app);import '../lib/http-polyfills';
import { authMiddleware } from 'better-convex/auth';
import { createHttpRouter } from 'better-convex/server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { router } from '../lib/crpc';
import { getAuth } from './auth';
const app = new Hono();
// CORS for API routes
app.use(
'/api/*',
cors({
origin: process.env.SITE_URL!,
allowHeaders: ['Content-Type', 'Authorization', 'Better-Auth-Cookie'],
exposeHeaders: ['Set-Better-Auth-Cookie'],
credentials: true,
})
);
// Better Auth middleware
app.use(authMiddleware(getAuth));
export const appRouter = router({
// Add your routers here
});
export default createHttpRouter(app, appRouter);Step 7: Update auth-client.ts
Now we'll update the client-side auth setup.
"use client";
import { convexClient } from "@convex-dev/better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
plugins: [
convexClient(),
// ... other plugins
],
});import { convexClient } from "@convex-dev/better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import { createAuthMutations } from "better-convex/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_SITE_URL!,
plugins: [
convexClient(),
// ... other plugins
],
});
// Export mutation hooks for TanStack Query
export const {
useSignOutMutationOptions,
useSignInSocialMutationOptions,
useSignInMutationOptions,
useSignUpMutationOptions,
} = createAuthMutations(authClient);The new createAuthMutations helper generates TanStack Query mutation options for common auth operations.
Step 8: Update Provider
Replace ConvexBetterAuthProvider with ConvexAuthProvider.
"use client";
import { ConvexReactClient } from "convex/react";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { authClient } from "@/lib/auth-client";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({ children, initialToken }) {
return (
<ConvexBetterAuthProvider
client={convex}
authClient={authClient}
initialToken={initialToken}
>
{children}
</ConvexBetterAuthProvider>
);
}"use client";
import { ConvexReactClient } from "convex/react";
import { ConvexAuthProvider } from "better-convex/auth-client";
import { authClient } from "@/lib/convex/auth-client";
import { useRouter } from "next/navigation";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function BetterConvexProvider({ children, token }) {
const router = useRouter();
return (
<ConvexAuthProvider
authClient={authClient}
client={convex}
initialToken={token}
onMutationUnauthorized={() => {
router.push("/login");
}}
onQueryUnauthorized={({ queryName }) => {
router.push("/login");
}}
>
{children}
</ConvexAuthProvider>
);
}New features:
onMutationUnauthorized- Handle auth errors on mutationsonQueryUnauthorized- Handle auth errors on queries (includesqueryNamefor debugging)
Step 9: Remove AuthBoundary
If you were using AuthBoundary, you can now handle auth errors via provider callbacks instead.
If you used AuthBoundary:
import { AuthBoundary } from "@convex-dev/better-auth/react";
export const ClientAuthBoundary = ({ children }) => {
const router = useRouter();
return (
<AuthBoundary
authClient={authClient}
onUnauth={() => router.push("/sign-in")}
getAuthUserFn={api.auth.getAuthUser}
isAuthError={isAuthError}
>
{children}
</AuthBoundary>
);
};Remove AuthBoundary and use the provider's onQueryUnauthorized and onMutationUnauthorized callbacks instead (configured in Step 8).
Step 10: Update Schema
If you were using the auto-generated component schema, you'll now define auth tables in your main schema.
Generate auth tables using the Better Auth CLI:
npx @better-auth/cli generate -y --output convex/functions/authSchema.ts --config convex/functions/auth.tsAuto selection: authClient.adapter(...).createSchema detects ORM schema metadata (Symbol.for("better-convex:OrmSchemaOptions")).
- With ORM metadata, CLI output uses
better-convex/orm(convexTable,defineSchema) and includes.unique()plus.references(...). - Without ORM metadata, CLI output stays on the existing Convex
defineTableformat. - ORM output intentionally does not emit
onDelete/cascade action config.
Then import in your schema:
import { defineSchema } from 'convex/server';
import { authSchema } from './authSchema';
export default defineSchema({
...authSchema,
// Your other tables
});See Auth Server Setup for the full schema definition.
Step 11: Migrate Helper Methods
The component pattern provided several helper methods on authComponent. These now use direct ctx.db access.
getAuthUser / safeGetAuthUser
// In a query or mutation
const user = await authComponent.getAuthUser(ctx); // throws if not found
const user = await authComponent.safeGetAuthUser(ctx); // returns nullimport { getAuthUserIdentity } from 'better-convex/auth';
// In a query or mutation
export const myQuery = query({
handler: async (ctx) => {
const identity = await getAuthUserIdentity(ctx);
if (!identity) return null; // or throw
// Use ctx.db directly
const user = await ctx.db.get('user', identity.userId);
if (!user) throw new ConvexError("User not found");
return user;
},
});getHeaders
const headers = await authComponent.getHeaders(ctx);import { getHeaders } from 'better-convex/auth';
// In a query or mutation
const headers = await getHeaders(ctx);getAuth
const { auth, headers } = await authComponent.getAuth(getAuth, ctx);
const session = await auth.api.getSession({ headers });import { getHeaders, getSession } from 'better-convex/auth';
// Option 1: Use helper for common operations
const session = await getSession(ctx);
// Option 2: Use Better Auth API directly
const headers = await getHeaders(ctx);
const auth = getAuth(ctx); // Your getAuth function from auth.ts
const session = await auth.api.getSession({ headers });clientApi().getAuthUser
export const { getAuthUser } = authComponent.clientApi();import { getAuthUserIdentity } from 'better-convex/auth';
export const getAuthUser = query({
handler: async (ctx) => {
const identity = await getAuthUserIdentity(ctx);
if (!identity) throw new ConvexError("Unauthenticated");
return await ctx.db.get('user', identity.userId);
},
});Migration Checklist
- Dependencies: Remove
@convex-dev/better-auth, installbetter-convex - convex.config.ts: Remove
app.use(betterAuth)line - Component data: Migrate
betterAuthcomponent data to app namespace before deletion - betterAuth folder: Delete the entire
convex/betterAuth/directory - auth.config.ts: Add
jwks: process.env.JWKSoption - auth.ts: Rewrite using new
createClientandcreateApipatterns - auth-client.ts: Add
createAuthMutations, update imports - Provider: Replace
ConvexBetterAuthProviderwithConvexAuthProvider - AuthBoundary: Remove and use provider callbacks
- Helper methods: Replace
authComponent.getAuthUseretc withctx.dbaccess - Schema: Generate or define auth tables in main schema
- Env vars: Run
npx better-convex env sync --authfor JWKS - Test: Verify sign-in, sign-up, session persistence, and token refresh
Next.js Migration
If you're using Next.js with cRPC, migrate the server-side helpers.
Without cRPC
If you're not using cRPC, keep using @convex-dev/better-auth/nextjs for server-side helpers. Still follow Steps 1-9 for the Convex backend and Step 7 for the provider migration.
Server setup (unchanged):
import { convexBetterAuthNextJs } from "@convex-dev/better-auth/nextjs";
export const { handler, getToken } = convexBetterAuthNextJs({
convexUrl: process.env.NEXT_PUBLIC_CONVEX_URL!,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});API route (unchanged):
import { handler } from "@/lib/auth-server";
export const { GET, POST } = handler;Root layout (unchanged):
import { ConvexClientProvider } from "./ConvexClientProvider";
import { getToken } from "@/lib/auth-server";
export default async function RootLayout({ children }) {
const token = await getToken();
return (
<html lang="en">
<body>
<ConvexClientProvider initialToken={token}>{children}</ConvexClientProvider>
</body>
</html>
);
}With cRPC
Server setup:
import { convexBetterAuthNextJs } from "@convex-dev/better-auth/nextjs";
export const { handler, getToken } = convexBetterAuthNextJs({
convexUrl: process.env.NEXT_PUBLIC_CONVEX_URL!,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});import { api } from '@convex/api';
import { meta } from '@convex/meta';
import { convexBetterAuth } from 'better-convex/auth-nextjs';
export const { createContext, createCaller, handler } = convexBetterAuth({
api,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
meta,
});Root layout:
import { ConvexClientProvider } from "./ConvexClientProvider";
import { getToken } from "@/lib/auth-server";
export default async function RootLayout({ children }) {
const token = await getToken();
return (
<html lang="en">
<body>
<ConvexClientProvider initialToken={token}>{children}</ConvexClientProvider>
</body>
</html>
);
}import { BetterConvexProvider } from "@/lib/convex/convex-provider";
import { createCaller } from "@/lib/convex/server";
export default async function RootLayout({ children }) {
const token = await createCaller().getToken();
return (
<html lang="en">
<body>
<BetterConvexProvider token={token}>{children}</BetterConvexProvider>
</body>
</html>
);
}TanStack Start Migration
For TanStack Start, follow Steps 1-9 above for core auth migration. This section covers framework-specific setup.
Server Setup
Create server-side auth helpers using @convex-dev/better-auth:
import { convexBetterAuthReactStart } from '@convex-dev/better-auth/react-start';
export const { handler, getToken } = convexBetterAuthReactStart({
convexUrl: import.meta.env.VITE_CONVEX_URL!,
convexSiteUrl: import.meta.env.VITE_CONVEX_SITE_URL!,
});API Route
Create the auth API route handler:
import { handler } from '@/lib/auth-server';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/api/auth/$')({
server: {
handlers: {
GET: ({ request }) => handler(request),
POST: ({ request }) => handler(request),
},
},
});Router Setup
Configure the router with ConvexQueryClient:
import { createRouter as createTanStackRouter } from '@tanstack/react-router';
import { QueryClient, notifyManager } from '@tanstack/react-query';
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query';
import { ConvexQueryClient } from '@convex-dev/react-query';
import { routeTree } from './routeTree.gen';
export function getRouter() {
if (typeof document !== 'undefined') {
notifyManager.setScheduler(window.requestAnimationFrame);
}
const convexUrl = import.meta.env.VITE_CONVEX_URL!;
const convexQueryClient = new ConvexQueryClient(convexUrl, {
expectAuth: true,
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryKeyHashFn: convexQueryClient.hashFn(),
queryFn: convexQueryClient.queryFn(),
},
},
});
convexQueryClient.connect(queryClient);
const router = createTanStackRouter({
routeTree,
context: { queryClient, convexQueryClient },
scrollRestoration: true,
});
setupRouterSsrQueryIntegration({ router, queryClient });
return router;
}Root Route
Set up the root route with auth provider and SSR token:
import {
Outlet,
createRootRouteWithContext,
useRouteContext,
} from '@tanstack/react-router';
import { QueryClient } from '@tanstack/react-query';
import { ConvexQueryClient } from '@convex-dev/react-query';
import { ConvexAuthProvider } from 'better-convex/auth-client';
import { authClient } from '@/lib/auth-client';
import { createServerFn } from '@tanstack/react-start';
import { getToken } from '@/lib/auth-server';
const getAuth = createServerFn({ method: 'GET' }).handler(async () => {
return await getToken();
});
export const Route = createRootRouteWithContext<{
queryClient: QueryClient;
convexQueryClient: ConvexQueryClient;
}>()({
beforeLoad: async (ctx) => {
const token = await getAuth();
// Set auth token for SSR queries
if (token) {
ctx.context.convexQueryClient.serverHttpClient?.setAuth(token);
}
return {
isAuthenticated: !!token,
token,
};
},
component: RootComponent,
});
function RootComponent() {
const context = useRouteContext({ from: Route.id });
return (
<ConvexAuthProvider
client={context.convexQueryClient.convexClient}
authClient={authClient}
initialToken={context.token}
>
<Outlet />
</ConvexAuthProvider>
);
}Protected Routes
Create a layout route for authenticated pages:
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/_authed')({
beforeLoad: ({ context }) => {
if (!context.isAuthenticated) {
throw redirect({ to: '/sign-in' });
}
},
component: () => <Outlet />,
});Sign-In Route
Redirect authenticated users away from sign-in:
import { createFileRoute, redirect } from '@tanstack/react-router';
import { SignIn } from '@/components/SignIn';
export const Route = createFileRoute('/sign-in')({
component: SignIn,
beforeLoad: ({ context }) => {
if (context.isAuthenticated) {
throw redirect({ to: '/' });
}
},
});Sign Out
Handle sign out with a page reload (required for expectAuth):
import { authClient } from '@/lib/auth-client';
export function SignOutButton() {
const handleSignOut = async () => {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
// Reload required when using expectAuth
location.reload();
},
},
});
};
return <button onClick={handleSignOut}>Sign out</button>;
}Troubleshooting
"authFunctions is undefined"
Run npx convex dev first to generate the internal API, then reference it correctly:
const authFunctions: AuthFunctions = internal.auth;"Auth triggers not firing"
Auth triggers must be defined in createClient({ triggers: ... }). They only fire through Better Auth flows, not direct database operations.
Next Steps
Done! Your auth is now migrated. Here's what to explore next: