OFFWORLD DOCS

Authentication

better-auth 1.4.7 and @convex-dev/better-auth 0.10.4 integration patterns

Guide to authentication patterns in the Offworld codebase using Better Auth with Convex.

Overview

Package Versions

  • better-auth: 1.4.7
  • @convex-dev/better-auth: ^0.10.4

For the official Convex Better Auth documentation, see labs.convex.dev/better-auth.


Convex Better-Auth Integration

Auth Configuration

Key changes in @convex-dev/better-auth 0.10.4:

  • New customJwt auth config for faster JWT validation
  • Simplified server utilities via convexBetterAuthReactStart
  • SSR improvements with initialToken prop

Backend (packages/backend/convex/auth.config.ts):

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

export default {
  providers: [getAuthConfigProvider()],
} satisfies AuthConfig;

Backend (packages/backend/convex/auth.ts):

import authConfig from "./auth.config";

export const createAuth = (ctx: GenericCtx<DataModel>) => {
  return betterAuth({
    // ... other config
    plugins: [
      convex({
        authConfig,
        jwksRotateOnTokenGenerationError: true, // Key rotation during migration
      }),
    ],
  });
};

Frontend (apps/web/src/lib/auth-server.ts):

import { convexBetterAuthReactStart } from "@convex-dev/better-auth/react-start";

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

SSR Token Handling

Root route (routes/__root.tsx):

import { getToken } from "@/lib/auth-server";

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

export const Route = createRootRoute({
  beforeLoad: async (ctx) => {
    const token = await getAuth();
    if (token) {
      ctx.context.convexQueryClient.serverHttpClient?.setAuth(token);
    }
    return { isAuthenticated: !!token, token };
  },
  component: RootComponent,
});

function RootComponent() {
  const { token } = Route.useRouteContext();
  
  return (
    <ConvexBetterAuthProvider
      client={context.convexQueryClient.convexClient}
      authClient={authClient}
      initialToken={token} // Pass token for SSR hydration
    >
      <Outlet />
    </ConvexBetterAuthProvider>
  );
}

Sign Out Handling

Reloading on sign out ensures clean state:

const handleSignOut = async () => {
  await authClient.signOut({
    fetchOptions: {
      onSuccess: () => {
        location.reload(); // Clean slate
      },
    },
  });
};

Why Not expectAuth: true

We intentionally do NOT use expectAuth: true in our ConvexQueryClient configuration. This is a deliberate architectural decision for apps with public routes.

Problem with expectAuth: true:

  • Pauses the WebSocket connection until setAuth() is called
  • For unauthenticated users, the WebSocket stays paused forever
  • All queries hang indefinitely on client-side navigation
  • Only initial SSR works (uses HTTP, not WebSocket)

Our approach:

  • Allow WebSocket to connect immediately
  • Use useConvexAuth() to gate auth-dependent features
  • Accept brief "flash" of unauthenticated state for authenticated users

Auth Query Patterns

Pattern 1: Public Routes with Optional Auth Check

For public routes that show different UI based on auth state:

function RepoSummaryPage() {
  // Public data with suspense (preloaded in loader)
  const { data: repoData } = useSuspenseQuery(
    convexQuery(api.repos.getByFullName, { fullName })
  );

  // Auth check with NON-suspense query (doesn't block)
  const { data: currentUser } = useQuery(
    convexQuery(api.auth.getCurrentUserSafe, {})
  );
  const isAuthenticated = currentUser !== null && currentUser !== undefined;

  return (
    <div>
      {repoData.summary}
      {isAuthenticated ? <IndexButton /> : <SignInPrompt />}
    </div>
  );
}

Pattern 2: Auth-Required Routes with useConvexAuth()

For routes that require authentication:

import { useConvexAuth } from "convex/react";
import Loader from "@/components/loader";

function ChatPage() {
  // Wait for Convex to validate auth token
  const { isAuthenticated, isLoading: isAuthLoading } = useConvexAuth();

  // Public data with suspense
  const { data: repoData } = useSuspenseQuery(
    convexQuery(api.repos.getByFullName, { fullName })
  );

  // Show loading while Convex validates auth token
  if (isAuthLoading) {
    return <Loader />;
  }

  // Not authenticated
  if (!isAuthenticated) {
    return <SignInRequired />;
  }

  // Safe to render auth-dependent content
  return <AuthenticatedChatUI />;
}

Used in: Chat, refresh routes

Pattern 3: Auth-Required Routes with Conditional Queries

For auth-required routes with auth-dependent queries:

function ChatThreadPage() {
  const { isAuthenticated, isLoading: isAuthLoading } = useConvexAuth();

  // Public data with suspense
  const { data: repoData } = useSuspenseQuery(
    convexQuery(api.repos.getByFullName, { fullName })
  );

  // Auth-dependent query - only runs when authenticated
  const { data: conversation, isLoading: isConversationLoading } = useQuery({
    ...convexQuery(api.chat.getConversation, { conversationId }),
    enabled: isAuthenticated, // Don't run until auth is confirmed
  });

  if (isAuthLoading) return <Loader />;
  if (!isAuthenticated) return <SignInRequired />;
  if (isConversationLoading) return <Loader />;
  if (!conversation) return <NotFound />;

  return <ChatInterface conversation={conversation} />;
}

Pattern 4: Shared Components

For components used outside of route context (e.g., header):

import { useQuery } from "@tanstack/react-query"; // NOT useSuspenseQuery

export default function UserMenu() {
  // Non-suspense query - handles loading/undefined states manually
  const { data: user } = useQuery(
    convexQuery(api.auth.getCurrentUserSafe, {})
  );

  if (!user) {
    return <SignInButton />;
  }

  return <UserDropdown user={user} />;
}

Pattern Summary Table

Route TypeAuth HandlingQuery Type
Public with auth UIuseQuery(getCurrentUserSafe)Mixed
Auth-requireduseConvexAuth()useSuspenseQuery
Auth + conditional queryuseConvexAuth() + enableduseQuery with enabled
Header/shareduseQuery(getCurrentUserSafe)useQuery

Troubleshooting

Page Shows Loading Then Content Flashes

Symptom: Authenticated user sees brief unauthenticated state.

Cause: Without expectAuth: true, queries run before auth token validation.

Solution: Use useConvexAuth() to gate rendering:

const { isAuthenticated, isLoading } = useConvexAuth();
if (isLoading) return <Loader />;

Auth-Required Query Throws Error

Symptom: Query that requires authentication throws an error.

Cause: Query runs before useConvexAuth() confirms authentication.

Solution: Use enabled flag:

const { data } = useQuery({
  ...convexQuery(api.auth.privateData, {}),
  enabled: isAuthenticated,
});

Next Steps

On this page