From Better Auth Component
Migrate from @convex-dev/better-auth to kitcn.
In this guide, we'll migrate from @convex-dev/better-auth (the component-based auth package) to kitcn. 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 | kitcn |
|---|---|---|
| Architecture | Convex component pattern | Direct integration |
| Triggers | Not built-in | triggers: { user, session } |
| Auth Contract | createAuthOptions/getAuth split | default defineAuth(() => ({ ...options, triggers })) |
| DB Adapter | authComponent.adapter(ctx) | Generated getAuth(ctx) (context-aware adapter) |
| Provider | ConvexBetterAuthProvider | ConvexAuthProvider |
| React Utils | AuthBoundary | createAuthMutations() on the kitcn path, plain authClient on the raw Convex preset |
Let's migrate step by step.
Step 1: 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.
Step 2: Rebuild Auth
From here, choose the path that matches how you want to migrate the auth files.
If you only want auth and you are keeping this as a plain Convex app, use the CLI path.
Run the auth scaffold
npx kitcn add auth --preset convex --yesThis scaffolds the auth files, runs the auth bootstrap, and keeps the app on the plain Convex path.
Review the generated auth config
The generated auth.config.ts starts with getAuthConfigProvider(). Keep
that generated file as-is on the raw Convex preset. This path does not
switch over to the kitcn getEnv()-based config shape.
Done
That's it. The CLI path already updates the auth contract, HTTP routes, client wiring, provider wiring, and schema integration for you without pulling in the wider kitcn scaffold.
If you want full control over each file, work through these steps in order.
Update Dependencies
Install kitcn.
bun add kitcnUpdate 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 "kitcn/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.
Generate Runtime
If using cRPC, run kitcn dev (generates all runtime including cRPC modules):
npx kitcn devOtherwise, run a scoped generation pass:
npx kitcn codegen --scope authMigrate auth.ts
This is the biggest change in the migration. We're replacing the component-based pattern — where you manually created a client, wired up adapters, and exported runtime helpers — with a single defineAuth contract. You declare your auth options and triggers in one place, and kitcn generates all the runtime for you.
Here's what the before and after looks like:
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 { admin } from "better-auth/plugins";
import { convex } from "kitcn/auth";
import authConfig from "./auth.config";
import { defineAuth } from "./generated/auth";
export default defineAuth(() => ({
baseURL: process.env.SITE_URL!,
emailAndPassword: { enabled: true },
plugins: [
convex({
authConfig,
jwks: process.env.JWKS,
}),
admin(),
],
triggers: {
user: {
create: {
before: async (data, triggerCtx) => {
return { data };
},
after: async (user, triggerCtx) => {
},
},
},
},
}));Notice how much simpler the new version is.
Here's a summary of what changed and why:
| Before | After | Why |
|---|---|---|
createAuthOptions/getAuth split | Single defineAuth(() => ({ ... })) | One contract, less boilerplate |
Runtime exports from auth.ts | Generated runtime in <functionsDir>/generated/auth.ts | Codegen handles wiring for you |
authComponent.adapter(ctx) | Generated getAuth(ctx) with context-aware adapter | Automatic direct DB in queries, HTTP adapter in actions |
Trigger signature (ctx, ...) | Trigger signatures (..., ctx) | Full parity with ORM defineTriggers callbacks |
| Component namespace routing | Generated contract + internal.generated.* handlers | No more component indirection |
Context-aware adapter: The generated getAuth(ctx) automatically picks the right adapter. In queries and mutations, it uses direct DB access for performance. In actions and HTTP contexts, it uses the HTTP adapter with ctx.run* APIs.
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 { registerRoutes } from 'kitcn/auth/http';
import { httpRouter } from 'convex/server';
import { getAuth } from './generated/auth';
const http = httpRouter();
registerRoutes(http, getAuth, {
cors: {
allowedOrigins: [process.env.SITE_URL!],
},
});
export default http;import { authMiddleware } from 'kitcn/auth/http';
import { HttpRouterWithHono } from 'kitcn/server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { getAuth } from './generated/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 { authMiddleware } from 'kitcn/auth/http';
import { createHttpRouter } from 'kitcn/server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { router } from '../lib/crpc';
import { getAuth } from './generated/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 httpRouter = router({
// Add your routers here
});
export default createHttpRouter(app, httpRouter);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 "kitcn/auth/client";
import { createAuthClient } from "better-auth/react";
import { createAuthMutations } from "kitcn/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.
This is the kitcn client surface. If you are keeping a plain Convex app
with kitcn add auth --preset convex --yes, keep the generated auth
client scaffold. That preset uses createAuthClient(...) plus convexClient()
and does not add createAuthMutations.
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 "kitcn/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 AppConvexProvider({ 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)
If you are keeping the raw Convex preset, keep the provider shell the preset
patched in place. The raw path adds ConvexAuthProvider, but it does not
assume the larger kitcn provider layout.
Remove AuthBoundary
Remove AuthBoundary and use the provider's onQueryUnauthorized and onMutationUnauthorized callbacks instead.
Update Schema
If you were using the auto-generated component schema, you'll now define auth tables in your main schema.
Refresh the full auth scaffold once:
npx kitcn add auth --yesAfter changing plugins or auth fields in <functionsDir>/auth.ts, refresh
only the auth-owned schema blocks:
npx kitcn add auth --schema --yesRefresh the auth tables:
npx kitcn add auth --preset convex --yeskitcn refreshes <functionsDir>/authSchema.ts and patches <functionsDir>/schema.ts to import and spread authSchema.
For fully manual table definitions, see Auth Server Setup.
Step 3: 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 'kitcn/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 'kitcn/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 'kitcn/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); // generated getAuth from <functionsDir>/generated/auth.ts
const session = await auth.api.getSession({ headers });clientApi().getAuthUser
export const { getAuthUser } = authComponent.clientApi();import { getAuthUserIdentity } from 'kitcn/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: Install
kitcn - 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.ts: Rewrite to
export default defineAuth(() => ({ ...options, triggers })) - Helper methods: Replace
authComponent.getAuthUseretc withctx.dbaccess - Test: Verify sign-in, sign-up, session persistence, and token refresh
kitcn path
- Run
npx kitcn add auth --yes - Use
npx kitcn add auth --schema --yesafter auth table changes - Use the
getEnv()-based auth config shape - Add
createAuthMutations, update auth client imports - Replace provider wiring with the kitcn auth-aware provider surface
- Use
npx kitcn devfor ongoing local runtime and codegen
Raw Convex preset
- Run
npx kitcn add auth --preset convex --yes - Keep generated
auth.config.tsscaffolded withgetAuthConfigProvider() - Keep the generated plain auth client scaffold; do not add
createAuthMutationsunless you are choosing the richer kitcn client surface yourself - Re-run the full preset command after auth schema changes;
--schemadoes not apply here - Keep the app on the plain Convex provider/router shape the preset patched in place
- Use
npx kitcn env push --prodfor production JWKS sync
Next.js Migration
If you're using Next.js, you can migrate server-side auth helpers to kitcn/auth/nextjs even when you do not use cRPC.
If this app only uses Next.js auth helpers after migration, you can remove @convex-dev/better-auth.
Without cRPC
If you're not using cRPC, you can still use kitcn/auth/nextjs.
Only handler and createCaller().getToken() are needed.
Server setup:
import { api } from "@convex/api";
import { convexBetterAuth } from "kitcn/auth/nextjs";
export const { createCaller, handler } = convexBetterAuth({
api,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});API route:
import { handler } from "@/lib/auth-server";
export const { GET, POST } = handler;Root layout:
import { ConvexClientProvider } from "./ConvexClientProvider";
import { createCaller } from "@/lib/auth-server";
export default async function RootLayout({ children }) {
const token = await createCaller().getToken();
return (
<html lang="en">
<body>
<ConvexClientProvider initialToken={token}>{children}</ConvexClientProvider>
</body>
</html>
);
}kitcn/auth/nextjs does not require cRPC. cRPC is only needed if you also use server callers beyond auth.
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 { convexBetterAuth } from 'kitcn/auth/nextjs';
export const { createContext, createCaller, handler } = convexBetterAuth({
api,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});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 { AppConvexProvider } 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>
<AppConvexProvider token={token}>{children}</AppConvexProvider>
</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 'kitcn/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
"Auth runtime is disabled ..."
Generated runtime can be disabled when <functionsDir>/auth.ts is missing or lacks a default export. functionsDir comes from convex.json.functions (default: convex), so scaffolded kitcn apps use convex/functions/auth.ts.
import { defineAuth } from './generated/auth';
export default defineAuth(() => ({
// ...options
}));Then run:
npx kitcn codegenBetter Auth CLI says config is invalid / cannot read auth config
Use the kitcn auth scaffold command for your mode:
npx kitcn add auth --yes
npx kitcn add auth --schema --yesFor raw Convex adoption, use:
npx kitcn add auth --preset convex --yesThis path assumes the raw Convex app is already initialized. If it still fails,
re-run kitcn codegen first so generated exports are up to date.
Next Steps
Done! Your auth is now migrated. Here's what to explore next: