kitcn

Migrations

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-authkitcn
ArchitectureConvex component patternDirect integration
TriggersNot built-intriggers: { user, session }
Auth ContractcreateAuthOptions/getAuth splitdefault defineAuth(() => ({ ...options, triggers }))
DB AdapterauthComponent.adapter(ctx)Generated getAuth(ctx) (context-aware adapter)
ProviderConvexBetterAuthProviderConvexAuthProvider
React UtilsAuthBoundarycreateAuthMutations() 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 --yes

This 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 kitcn

Update auth.config.ts

The auth config stays similar. We just need to ensure we're passing the static JWKS.

Before
import { getAuthConfigProvider } from "@convex-dev/better-auth/auth-config";
import type { AuthConfig } from "convex/server";

export default {
  providers: [getAuthConfigProvider()],
} satisfies AuthConfig;
After
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 dev

Otherwise, run a scoped generation pass:

npx kitcn codegen --scope auth

Migrate 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:

Before (component pattern)
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);
  },
});
After (direct pattern)
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:

BeforeAfterWhy
createAuthOptions/getAuth splitSingle defineAuth(() => ({ ... }))One contract, less boilerplate
Runtime exports from auth.tsGenerated runtime in <functionsDir>/generated/auth.tsCodegen handles wiring for you
authComponent.adapter(ctx)Generated getAuth(ctx) with context-aware adapterAutomatic direct DB in queries, HTTP adapter in actions
Trigger signature (ctx, ...)Trigger signatures (..., ctx)Full parity with ORM defineTriggers callbacks
Component namespace routingGenerated contract + internal.generated.* handlersNo 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.

Before (convex/http.ts)
import { httpRouter } from 'convex/server';
import { authComponent, getAuth } from './auth';

const http = httpRouter();

authComponent.registerRoutes(http, getAuth);

export default http;
After (convex/functions/http.ts)

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.

Before (lib/auth-client.tsx)
"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
  ],
});
After (lib/convex/auth-client.ts)
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.

Before (app/ConvexClientProvider.tsx)
"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>
  );
}
After (lib/convex/convex-provider.tsx)
"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 mutations
  • onQueryUnauthorized - Handle auth errors on queries (includes queryName for 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 --yes

After changing plugins or auth fields in <functionsDir>/auth.ts, refresh only the auth-owned schema blocks:

npx kitcn add auth --schema --yes

Refresh the auth tables:

npx kitcn add auth --preset convex --yes

kitcn 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

Before
// In a query or mutation
const user = await authComponent.getAuthUser(ctx); // throws if not found
const user = await authComponent.safeGetAuthUser(ctx); // returns null
After
import { 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

Before
const headers = await authComponent.getHeaders(ctx);
After
import { getHeaders } from 'kitcn/auth';

// In a query or mutation
const headers = await getHeaders(ctx);

getAuth

Before
const { auth, headers } = await authComponent.getAuth(getAuth, ctx);
const session = await auth.api.getSession({ headers });
After
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

Before (convex/auth.ts)
export const { getAuthUser } = authComponent.clientApi();
After (convex/functions/queries.ts)
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 betterAuth component 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.getAuthUser etc with ctx.db access
  • 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 --yes after 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 dev for ongoing local runtime and codegen

Raw Convex preset

  • Run npx kitcn add auth --preset convex --yes
  • Keep generated auth.config.ts scaffolded with getAuthConfigProvider()
  • Keep the generated plain auth client scaffold; do not add createAuthMutations unless you are choosing the richer kitcn client surface yourself
  • Re-run the full preset command after auth schema changes; --schema does not apply here
  • Keep the app on the plain Convex provider/router shape the preset patched in place
  • Use npx kitcn env push --prod for 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:

Before (lib/auth-server.ts)
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!,
});
After (lib/convex/server.ts)
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:

Before (app/layout.tsx)
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>
  );
}
After (app/layout.tsx)
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 codegen

Better 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 --yes

For raw Convex adoption, use:

npx kitcn add auth --preset convex --yes

This 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:

On this page