BETTER-CONVEX

Migrations

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-authbetter-convex
ArchitectureConvex component patternDirect integration
TriggersNot built-intriggers: { user, session }
Client CreationcreateClient(components.betterAuth, {...})createClient({ authFunctions, schema })
DB AdapterauthComponent.adapter(ctx)authClient.adapter(ctx, getAuthOptions)
ProviderConvexBetterAuthProviderConvexAuthProvider
React UtilsAuthBoundarycreateAuthMutations(), 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-convex

Step 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.zip

2) 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.ts

3) Install migration zip dependencies

npm i -D yazl yauzl

4) Run the migration utility (creates a new migration.zip)

node --experimental-strip-types ./better-auth-component-migration/cli.ts \
  --snapshotZip ./snapshot.zip \
  --outputZip ./migration.zip

Optional: migrate only specific tables:

node --experimental-strip-types ./better-auth-component-migration/cli.ts \
  --snapshotZip ./snapshot.zip \
  --outputZip ./migration.zip \
  --filters=user,jwks

Optional: set a custom temp working directory:

node --experimental-strip-types ./better-auth-component-migration/cli.ts \
  --snapshotZip ./snapshot.zip \
  --outputZip ./migration.zip \
  --workDir ./snapshot.temp

5) Import manually

npx convex import ./migration.zip --replace -y

Files to delete:

  • convex/betterAuth/ folder (entire directory)
  • Remove app.use(betterAuth) from convex/convex.config.ts
Before (convex/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;
After (convex/convex.config.ts)
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.

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 "@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.

Before (component pattern)
convex/auth.ts
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)
convex/functions/auth.ts
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:

BeforeAfter
createClient(components.betterAuth, {...})createClient({ authFunctions, schema, triggers })
ORM context setupcontext
authComponent.adapter(ctx)authClient.adapter(ctx, getAuthOptions)
authComponent.clientApi()createApi() generates internal functions
No triggersBuilt-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:

convex/lib/http-polyfills.ts
// 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.

Before (convex/http.ts)
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:

convex/functions/http.ts
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;
convex/functions/http.ts
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);
convex/functions/http.ts
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.

Before (lib/auth-client.tsx)
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)
lib/convex/auth-client.ts
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.

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

Auto 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 defineTable format.
  • ORM output intentionally does not emit onDelete/cascade action config.

Then import in your schema:

convex/functions/schema.ts
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

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 '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

Before
const headers = await authComponent.getHeaders(ctx);
After
import { getHeaders } from 'better-convex/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 '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

Before (convex/auth.ts)
convex/auth.ts
export const { getAuthUser } = authComponent.clientApi();
After (convex/functions/queries.ts)
convex/functions/queries.ts
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, install better-convex
  • 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.config.ts: Add jwks: process.env.JWKS option
  • auth.ts: Rewrite using new createClient and createApi patterns
  • auth-client.ts: Add createAuthMutations, update imports
  • Provider: Replace ConvexBetterAuthProvider with ConvexAuthProvider
  • AuthBoundary: Remove and use provider callbacks
  • Helper methods: Replace authComponent.getAuthUser etc with ctx.db access
  • Schema: Generate or define auth tables in main schema
  • Env vars: Run npx better-convex env sync --auth for 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):

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!,
});

API route (unchanged):

app/api/auth/[...all]/route.ts
import { handler } from "@/lib/auth-server";

export const { GET, POST } = handler;

Root layout (unchanged):

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>
  );
}

With cRPC

Server setup:

Before (lib/auth-server.ts)
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)
lib/convex/server.ts
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:

Before (app/layout.tsx)
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)
app/layout.tsx
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:

src/lib/auth-server.ts
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:

src/routes/api/auth/$.ts
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:

src/router.tsx
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:

src/routes/__root.tsx
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:

src/routes/_authed.tsx
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:

src/routes/sign-in.tsx
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):

src/components/SignOutButton.tsx
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:

On this page