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:
| Feature | Description |
|---|---|
| Sign in/out mutations | TanStack Query hooks for auth flows |
| Auth state hooks | useAuth, useAuthGuard, useMaybeAuth, useIsAuth |
| Conditional rendering | Authenticated, Unauthenticated, MaybeAuthenticated |
| Provider callbacks | Handle unauthorized queries/mutations |
Let's start with setup.
Setup
Create an auth client with mutation hooks:
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
'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:
const createAuthOptions = (ctx: GenericCtx) =>
({
emailAndPassword: {
enabled: true,
},
// ... rest of config
}) satisfies BetterAuthOptions;Then use the sign in/up hooks:
'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
'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:
| Feature | Description |
|---|---|
| Auth query cleanup | useSignOutMutationOptions automatically calls unsubscribeAuthQueries() before signOut() to prevent UNAUTHORIZED errors from subscribed queries during logout |
| Proper loading state | The 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:
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>
);
}| Property | Description |
|---|---|
hasSession | Has a session token (may not be verified) |
isAuthenticated | Token exists AND Convex auth verified |
isLoading | Convex 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:
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:
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
| Prop | Type | Description |
|---|---|---|
client | ConvexReactClient | Convex client instance |
authClient | AuthClient | Better Auth client instance |
initialToken | string? | Initial session token (from SSR) |
onMutationUnauthorized | () => void | Called when mutation is blocked |
onQueryUnauthorized | ({ queryName }) => void | Called 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
| Point | Description |
|---|---|
| Token flows server → client | Via initialToken prop |
| Instant hydration | Prefetched queries hydrate immediately - no loading spinner |
| Defensive isLoading | Prevents UNAUTHORIZED errors during hydration race |
| Two sources sync together | Better 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:
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.