Convex + Better Auth
Migrations

Migrate to 0.10

Migrate to @convex-dev/better-auth@0.10

Upgrade component

Install dependencies

Update Better Auth and the component.

Check your dependencies for Better Auth packages and be sure to pin them all to 1.4.7. For example if you're using @better-auth/expo, it would also need to be upgraded.

npm install @convex-dev/better-auth@^0.10.0
npm install better-auth@1.4.7 --save-exact

Update auth config

Use customJwt auth config

customJwt auth config is now recommended for faster JWT validation. A helper is provided to generate the config object.

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

export default {
  providers: [
    getAuthConfigProvider(), 
    {
      applicationID: "convex",
      domain: process.env.CONVEX_SITE_URL!,
    },
  ],
} satisfies AuthConfig;

Update Convex Better Auth plugin config

The Convex Better Auth plugin now requires the auth config object. This helps minimize configuration and ensure accuracy.

Additionally, as this new configuration uses RS256 instead of EdDSA (for compatibility with customJwt), you'll want to set jwksRotateOnTokenGenerationError to true initially so keys can be rotated when algorithm mismatch occurs. This can be disabled later if desired.

convex/auth.ts
import authConfig from "./auth.config";

export const createAuthOptions = (ctx: GenericCtx<DataModel>) => {
  return {
    baseURL: siteUrl,
    database: authComponent.adapter(ctx),
    // ... other auth config
    plugins: [
      // ... other plugins
      convex(), 
      convex({
        authConfig,
        jwksRotateOnTokenGenerationError: true,
      }),
    ],
  } satisfies BetterAuthOptions;
};

Drop optionsOnly parameter from createAuth

The optionsOnly parameter is no longer needed.

convex/auth.ts
export const createAuth = (
  ctx: GenericCtx<DataModel>,
  { optionsOnly } = { optionsOnly: false },
) => {
export const createAuth = (ctx: GenericCtx<DataModel>) => { 
  return betterAuth({
    logger: {
      disabled: optionsOnly,
    },
    // ... auth config
  });
}

SSR improvements

The following steps are only necessary for apps using SSR (eg., Next.js, TanStack Start).

Update framework server utilities

Server utilities are provided for TanStack Start and Next.js to provide the same kind of set-it-and-forget-it auth management that you get in the client, on the server.

The configuration requires explicit URLs for Convex with runtime validation, which should reduce environment variable confusion.

Replace usage of previous versions of these functions with the new ones, eg., preloadQuery with preloadAuthQuery, fetchQuery with fetchAuthQuery, etc. Note that the new functions only accept a function reference and arguments, a third option with configuration such as a token is not accepted or required.

lib/auth-server.ts
import { createAuth } from "@/convex/auth"; 
import { getToken as getTokenNextjs } from "@convex-dev/better-auth/nextjs"; 
import { convexBetterAuthNextJs } from "@convex-dev/better-auth/nextjs"; 

export const getToken = () => {
  return getTokenNextjs(createAuth);
};

export const {
  handler,
  preloadAuthQuery,
  isAuthenticated,
  getToken,
  fetchAuthQuery,
  fetchAuthMutation,
  fetchAuthAction,
} = convexBetterAuthNextJs({
  convexUrl: process.env.NEXT_PUBLIC_CONVEX_URL!,
  convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});
src/lib/auth-server.ts
import { createAuth } from "convex/auth";
import { setupFetchClient } from "@convex-dev/better-auth/react-start";
import { getCookie } from "@tanstack/react-start/server";
import { convexBetterAuthReactStart } from "@convex-dev/better-auth/react-start"; 

export const { fetchQuery, fetchMutation, fetchAction } =
  await setupFetchClient(createAuth, getCookie);

export const {
  handler,
  getToken,
  fetchAuthQuery,
  fetchAuthMutation,
  fetchAuthAction,
} = convexBetterAuthReactStart({
  convexUrl: process.env.VITE_CONVEX_URL!,
  convexSiteUrl: process.env.VITE_CONVEX_SITE_URL!,
});

Update route handlers

Update your route handlers to use the new handler.

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

export const { GET, POST } = nextJsHandler(); 
export const { GET, POST } = handler; 
src/routes/api/auth/$.ts
import { createFileRoute } from '@tanstack/react-router'
import { reactStartHandler } from '@convex-dev/better-auth/react-start'
import { handler } from '~/lib/auth-server'

export const Route = createFileRoute('/api/auth/$')({
  server: {
    handlers: {
      GET: ({ request }) => reactStartHandler(request),
      POST: ({ request }) => reactStartHandler(request),
      GET: ({ request }) => handler(request),
      POST: ({ request }) => handler(request),
    },
  },
})

Pass initial token to ConvexBetterAuthProvider

Apps using Next.js or TanStack Start can pass the an initial token to speed up client authentication.

Update your ConvexClientProvider to accept an initial token.

app/ConvexClientProvider.tsx
export function ConvexClientProvider({
  children,
  initialToken, 
}: {
  children: ReactNode;
  initialToken?: string | null; 
}) {
  return (
    <ConvexBetterAuthProvider
      client={convex}
      authClient={authClient}
      initialToken={initialToken} 
    >
      {children}
    </ConvexBetterAuthProvider>
  );
}

Then fetch the initial token on the server from your root layout and pass it to the ConvexClientProvider.

app/layout.tsx
import { getToken } from "@/lib/auth-server"; 
import { ConvexClientProvider } from "./ConvexClientProvider";
import { PropsWithChildren } from "react";

export default async function RootLayout({ children }: PropsWithChildren) {
  const token = await getToken(); 
  return (
    <html>
      <body>
        <ConvexClientProvider>
        <ConvexClientProvider initialToken={token}>
          {children}
        </ConvexClientProvider>
      </body>
    </html>
  );
}
src/routes/__root.tsx
function RootComponent() {
  const context = useRouteContext({ from: Route.id })
  return (
    <ConvexBetterAuthProvider
      client={context.convexQueryClient.convexClient}
      authClient={authClient}
      initialToken={context.token} 
    >
      <RootDocument>
        <Outlet />
      </RootDocument>
    </ConvexBetterAuthProvider>
  )
}

Next.js changes

Use usePreloadedAuthQuery

usePreloadedAuthQuery replaces usePreloadedQuery as a drop-in - it ensures server rendered data is rendered until authentication is ready.

app/(auth)/(dashboard)/todo-list.tsx
import { usePreloadedAuthQuery } from "@convex-dev/better-auth/nextjs/client";

export const TodoList = ({ preloadedUserQuery }) => {
  const userQuery = usePreloadedAuthQuery(preloadedUserQuery);
  return (
    <div>
      <h1>{userQuery?.name}</h1>
    </div>
  );
};

TanStack Start changes

Update Vite configuration

Update Vite configuration to bundle @convex-dev/better-auth during SSR to avoid module resolution issues.

vite.config.ts
export default defineConfig({
  // ...other config
  ssr: {
    noExternal: ["@convex-dev/better-auth"],
  },
});

Update router config

Update the router to set expectAuth: true on the ConvexQueryClient - without this, authenticated data from server render is lost immediately after first render.

Also drop the unused Wrap provider (if you have one).

src/router.tsx
export function getRouter() {
  const convexUrl = (import.meta as any).env.VITE_CONVEX_URL!;
  if (!convexUrl) {
    throw new Error("VITE_CONVEX_URL is not set");
  }
  const convexQueryClient = new ConvexQueryClient(convexUrl, {
    expectAuth: true, 
  });

  const queryClient: QueryClient = new QueryClient({
    defaultOptions: {
      queries: {
        queryKeyHashFn: convexQueryClient.hashFn(),
        queryFn: convexQueryClient.queryFn(),
      },
    },
  });
  convexQueryClient.connect(queryClient);

  const router = createTanStackRouter({
    routeTree,
    defaultPreload: "intent",
    defaultErrorComponent: DefaultCatchBoundary,
    defaultNotFoundComponent: () => <NotFound />,
    Wrap: ({ children }) => (
      <ConvexProvider client={convexQueryClient.convexClient}>
        {children}
      </ConvexProvider>
    ),
    context: { queryClient, convexQueryClient },
    scrollRestoration: true,
  });
  setupRouterSsrQueryIntegration({
    router,
    queryClient,
  });

  return router;
}

Update root route

Update the root route to use the new getAuth() server function and isAuthenticated context value.

src/routes/__root.tsx
import { createServerFn } from "@tanstack/react-start";
import {
  fetchSession,
  getCookieName,
} from "@convex-dev/better-auth/react-start";
import { getCookie, getRequest } from "@tanstack/react-start/server"; 
import { getToken } from "@/lib/auth-server"; 

const fetchAuth = createServerFn({ method: "GET" }).handler(async () => {
  const { createAuth } = await import("@convex/auth");
  const { session } = await fetchSession(getRequest());
  const sessionCookieName = getCookieName(createAuth);
  const token = getCookie(sessionCookieName);
  return {
    userId: session?.user.id,
    token,
  };
});

const getAuth = createServerFn({ method: "GET" }).handler(async () => {
  return await getToken();
});

export const Route = createRootRouteWithContext<{
  queryClient: QueryClient;
  convexQueryClient: ConvexQueryClient;
}>()({
  // ... other config
  beforeLoad: async (ctx) => {
    const token = await getAuth();

    // all queries, mutations and actions through TanStack Query will be
    // authenticated during SSR if we have a valid token
    if (token) {
      // During SSR only (the only time serverHttpClient exists),
      // set the auth token to make HTTP queries with.
      ctx.context.convexQueryClient.serverHttpClient?.setAuth(token);
    }

    return {
      isAuthenticated: !!token,
      token,
    };
  },
  component: RootComponent,
});

Reload on sign out

Finally, update your signOut function(s) to reload the page. This allows expectAuth to work correctly for the next sign in. For apps with authenticated routes, signing out can typically replace a redirect.

src/lib/auth-client.ts
export const handleSignOut = async () => {
  const navigate = useNavigate();
  await authClient.signOut({
    fetchOptions: {
      onSuccess: () => {
        navigate({ to: "/" }); 
        location.reload(); 
      },
    },
  });
};

Local Install additional steps

The following steps are only necessary for apps using Local Install.

Remove getStaticAuth

Remove getStaticAuth in convex/betterAuth/auth.ts and replace it with a simple createAuth

convex/betterAuth/auth.ts
import { getStaticAuth } from "@convex-dev/better-auth"; 
import { v } from "convex/values";
import { createAuth } from "../auth";

// Export a static instance for Better Auth schema generation
export const auth = getStaticAuth(createAuth); 
export const auth = createAuth({} as any); 

Regenerate schema

Whenever updating Better Auth w/ Local Install, regenerate the schema.

cd convex/betterAuth
npx @better-auth/cli generate -y

Split out createAuthOptions function

The previous approach of configuring Better Auth options inside of the betterAuth() function call meant we had to run Better Auth to access options statically. This led to unnecessary logging and inaccurate error messages, and required workarounds like using optionsOnly to disable logging.

To avoid all of this moving forward, you'll want to have a separate createAuthOptions function that just returns the typed options object.

convex/auth.ts
import {
  betterAuth,
  type BetterAuthOptions, 
} from "better-auth";

export const createAuthOptions = (ctx: GenericCtx<DataModel>) => {
  return {
    // ... auth config
  } satisfies BetterAuthOptions;
};

export const createAuth = (ctx: GenericCtx<DataModel>) => {
  return betterAuth({
    // ... auth config
  });
  return betterAuth(createAuthOptions(ctx)); 
};

Update adapter

Replace usage of createAuth with createAuthOptions in adapter.ts.

You'll also drop the removed migrationRemoveUserId function.

convex/betterAuth/adapter.ts
import { createApi } from "@convex-dev/better-auth";
import {
  createAuth, 
  createAuthOptions, 
} from "./auth";
import schema from "./schema";

export const {
  create,
  findOne,
  findMany,
  updateOne,
  updateMany,
  deleteOne,
  deleteMany,
  migrationRemoveUserId, 
} = createApi(schema, createAuth); 
} = createApi(schema, createAuthOptions);