BETTER-CONVEX

Client

Client-side authentication with sign in, sign out, and auth hooks.

In this guide, we'll explore client-side authentication with better-convex. You'll learn to implement sign in/out flows, use auth state hooks, and render components conditionally based on authentication state.

Overview

Client-side auth utilities for React components:

FeatureDescription
Sign in/out mutationsTanStack Query hooks for auth flows
Auth state hooksuseAuth, useAuthGuard, useMaybeAuth, useIsAuth
Conditional renderingAuthenticated, Unauthenticated, MaybeAuthenticated
Provider callbacksHandle unauthorized queries/mutations

Let's start with setup.

Setup

Create an auth client with mutation hooks:

src/lib/convex/auth-client.ts
import { convexClient } from '@convex-dev/better-auth/client/plugins';
import { inferAdditionalFields } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/react';
import { createAuthMutations } from 'better-convex/react';

import type { Auth } from '@convex/auth-shared';

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_SITE_URL!,
  plugins: [inferAdditionalFields<Auth>(), convexClient()],
});

export const {
  useSignInMutationOptions,
  useSignInSocialMutationOptions,
  useSignOutMutationOptions,
  useSignUpMutationOptions,
} = createAuthMutations(authClient);

Sign In

Social Providers

src/components/login-form.tsx
'use client';

import { useMutation } from '@tanstack/react-query';
import { useSignInSocialMutationOptions } from '@/lib/convex/auth-client';

function LoginForm() {
  const signInSocial = useMutation(useSignInSocialMutationOptions());

  const handleGoogleSignIn = () => {
    signInSocial.mutate({
      callbackURL: window.location.origin,
      provider: 'google',
    });
  };

  const handleGithubSignIn = () => {
    signInSocial.mutate({
      callbackURL: window.location.origin,
      provider: 'github',
    });
  };

  return (
    <div>
      <button disabled={signInSocial.isPending} onClick={handleGoogleSignIn}>
        Continue with Google
      </button>
      <button disabled={signInSocial.isPending} onClick={handleGithubSignIn}>
        Continue with GitHub
      </button>
    </div>
  );
}

Email/Password

First enable email/password in your Convex auth config:

convex/functions/auth.ts
const createAuthOptions = (ctx: GenericCtx) =>
  ({
    emailAndPassword: {
      enabled: true,
    },
    // ... rest of config
  }) satisfies BetterAuthOptions;

Then use the sign in/up hooks:

src/components/login-form.tsx
'use client';

import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import {
  useSignInMutationOptions,
  useSignUpMutationOptions,
} from '@/lib/convex/auth-client';

function EmailLoginForm() {
  const [mode, setMode] = useState<'signin' | 'signup'>('signin');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [name, setName] = useState('');
  const router = useRouter();

  const signIn = useMutation(
    useSignInMutationOptions({
      onSuccess: () => router.push('/'),
    })
  );
  const signUp = useMutation(
    useSignUpMutationOptions({
      onSuccess: () => router.push('/'),
    })
  );

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    if (mode === 'signup') {
      signUp.mutate({
        callbackURL: window.location.origin,
        email,
        name,
        password,
      });
    } else {
      signIn.mutate({
        callbackURL: window.location.origin,
        email,
        password,
      });
    }
  };

  const isPending = signIn.isPending || signUp.isPending;

  return (
    <form onSubmit={handleSubmit}>
      {mode === 'signup' && (
        <input
          type="text"
          placeholder="Name"
          value={name}
          onChange={(e) => setName(e.target.value)}
          required
        />
      )}
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
      />
      <input
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        required
        minLength={8}
      />
      <button type="submit" disabled={isPending}>
        {mode === 'signup' ? 'Sign Up' : 'Sign In'}
      </button>
      <button type="button" onClick={() => setMode(mode === 'signin' ? 'signup' : 'signin')}>
        {mode === 'signin' ? "Don't have an account? Sign up" : 'Already have an account? Sign in'}
      </button>
    </form>
  );
}

Unlike OAuth (which redirects server-side), email/password auth requires a client-side redirect via onSuccess.

Sign Out

src/components/logout-button.tsx
'use client';

import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { useSignOutMutationOptions } from '@/lib/convex/auth-client';

function LogoutButton() {
  const router = useRouter();
  const signOut = useMutation(
    useSignOutMutationOptions({
      onSuccess: () => router.push('/login'),
      onError: () => toast.error('Failed to sign out'),
    })
  );

  return (
    <button disabled={signOut.isPending} onClick={() => signOut.mutate()}>
      {signOut.isPending ? 'Signing out...' : 'Sign out'}
    </button>
  );
}

Why createAuthMutations?

The hooks provide two key features:

FeatureDescription
Auth query cleanupuseSignOutMutationOptions automatically calls unsubscribeAuthQueries() before signOut() to prevent UNAUTHORIZED errors from subscribed queries during logout
Proper loading stateThe mutation's isPending stays true until the auth token is actually cleared (not just when the API call completes), preventing UI flicker

Client Hooks

useAuth

Get comprehensive auth state:

src/components/auth-status.tsx
import { useAuth } from 'better-convex/react';

function AuthStatus() {
  const { hasSession, isAuthenticated, isLoading } = useAuth();

  if (isLoading) return <Spinner />;

  return (
    <div>
      {isAuthenticated ? 'Logged in' : 'Logged out'}
    </div>
  );
}
PropertyDescription
hasSessionHas a session token (may not be verified)
isAuthenticatedToken exists AND Convex auth verified
isLoadingConvex auth is still loading

useMaybeAuth

Check if user maybe has auth (optimistic, has token):

import { useMaybeAuth } from 'better-convex/react';

function Component() {
  const isAuth = useMaybeAuth();
  return isAuth ? <LoggedInUI /> : <LoginButton />;
}

useIsAuth

Check if user is authenticated (server-verified):

import { useIsAuth } from 'better-convex/react';

function SecureComponent() {
  const isAuth = useIsAuth();
  return isAuth ? <SensitiveData /> : <Loading />;
}

useAuthGuard

Guard mutations that require authentication:

src/components/create-post.tsx
import { useAuthGuard } from 'better-convex/react';
import { useMutation } from '@tanstack/react-query';

function CreatePostButton() {
  const guard = useAuthGuard();
  const createPost = useMutation(crpc.post.create.mutationOptions());

  const handleClick = () => {
    // Returns true if blocked (not authenticated)
    if (guard()) return;

    // User is authenticated, safe to mutate
    createPost.mutate({ title: 'New Post' });
  };

  return <button onClick={handleClick}>Create Post</button>;
}

With callback:

const handleClick = () => {
  guard(async () => {
    // Only runs if authenticated
    await createPost.mutateAsync({ title: 'New Post' });
    toast.success('Post created!');
  });
};

Conditional Rendering

MaybeAuthenticated

Render children only when has session (optimistic):

import { MaybeAuthenticated } from 'better-convex/react';

function App() {
  return (
    <MaybeAuthenticated>
      <Dashboard />
    </MaybeAuthenticated>
  );
}

Authenticated

Render children only when server-verified:

import { Authenticated } from 'better-convex/react';

function App() {
  return (
    <Authenticated>
      <SensitiveData />
    </Authenticated>
  );
}

MaybeUnauthenticated

Render children only when no session (optimistic):

import { MaybeAuthenticated, MaybeUnauthenticated } from 'better-convex/react';

function App() {
  return (
    <>
      <MaybeAuthenticated>
        <Dashboard />
      </MaybeAuthenticated>
      <MaybeUnauthenticated>
        <LoginPage />
      </MaybeUnauthenticated>
    </>
  );
}

Unauthenticated

Render children only when not server-verified (waits for loading):

import { Unauthenticated } from 'better-convex/react';

function App() {
  return (
    <Unauthenticated>
      <LoginPage />
    </Unauthenticated>
  );
}

Provider Configuration

Configure auth callbacks in the provider:

src/app.tsx
import { ConvexAuthProvider } from 'better-convex/auth-client';

function App() {
  return (
    <ConvexAuthProvider
      client={convexClient}
      authClient={authClient}
      initialToken={serverToken}
      onMutationUnauthorized={() => {
        // Custom handler for unauthorized mutations
        openLoginModal();
      }}
      onQueryUnauthorized={({ queryName }) => {
        // Custom handler for unauthorized queries
        console.log(`Unauthorized query: ${queryName}`);
      }}
    >
      {children}
    </ConvexAuthProvider>
  );
}

Props

PropTypeDescription
clientConvexReactClientConvex client instance
authClientAuthClientBetter Auth client instance
initialTokenstring?Initial session token (from SSR)
onMutationUnauthorized() => voidCalled when mutation is blocked
onQueryUnauthorized({ queryName }) => voidCalled when query is blocked

Auth Flow

Let's understand how the SSR → client hydration flow works:

┌─────────────────────────────────────────────────────────────────────────────┐
│                              SERVER (RSC)                                    │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  1. caller.getToken()                                                        │
│     └─> Reads Better Auth session cookie                                     │
│     └─> Returns JWT token (or undefined)                                     │
│                                                                              │
│  2. prefetch(crpc.user.getCurrentUser.queryOptions())                        │
│     └─> Fetches user data via HTTP with token in Authorization header        │
│     └─> Stores result in server QueryClient                                  │
│                                                                              │
│  3. <BetterConvexProvider token={token}>                                     │
│     └─> Passes token to client as initialToken prop                          │
│                                                                              │
│  4. <HydrateClient>                                                          │
│     └─> Dehydrates QueryClient state for client hydration                    │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────────────┐
│                              CLIENT (Hydration)                              │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  5. ConvexAuthProvider mount                                                 │
│     └─> AuthProvider sets initial store: token, expiresAt                    │
│     └─> isLoading: true, isAuthenticated: false (waiting for Convex)         │
│                                                                              │
│  6. ConvexProviderWithAuth                                                   │
│     └─> Calls useAuth().fetchAccessToken with cached token                   │
│     └─> Convex backend validates JWT                                         │
│                                                                              │
│  7. AuthStateSync (inside ConvexProviderWithAuth)                            │
│     └─> useConvexAuth() returns { isLoading: true, isAuthenticated: false }  │
│     └─> Defensive: isLoading = true (has token but not yet authenticated)    │
│                                                                              │
│  8. Convex validates token                                                   │
│     └─> useConvexAuth() returns { isLoading: false, isAuthenticated: true }  │
│     └─> AuthStateSync syncs to store                                         │
│                                                                              │
│  9. HydrationBoundary                                                        │
│     └─> Hydrates client QueryClient with prefetched data                     │
│     └─> useQuery(crpc.user.getCurrentUser.queryOptions()) → instant data     │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Key Points

PointDescription
Token flows server → clientVia initialToken prop
Instant hydrationPrefetched queries hydrate immediately - no loading spinner
Defensive isLoadingPrevents UNAUTHORIZED errors during hydration race
Two sources sync togetherBetter Auth (cookie-based) and Convex (WebSocket-based)

React Native / @convex-dev/auth Users

If you're using @convex-dev/auth (common in React Native) instead of better-auth, import ConvexProviderWithAuth from better-convex/react:

App.tsx
import { ConvexProviderWithAuth } from 'better-convex/react';

function App() {
  return (
    <ConvexProviderWithAuth client={convex} useAuth={useAuthFromConvexDev}>
      <YourApp />
    </ConvexProviderWithAuth>
  );
}

This enables skipUnauth queries, useAuth, and conditional rendering components to work with @convex-dev/auth.

Next Steps

On this page