Client
Client-side authentication with sign in, sign out, and auth hooks.
Setup
Create an auth client with mutation hooks:
import { inferAdditionalFields } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/react';
import { convexClient } from 'kitcn/auth/client';
import { createAuthMutations } from 'kitcn/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);On Next.js, the scaffolded auth route lives on the same origin as the page, so
the client does not need an explicit baseURL. Add one only when your auth
handler lives on a different origin.
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:
import { defineAuth } from './generated/auth';
export default defineAuth((ctx) => ({
emailAndPassword: {
enabled: true,
},
// ... rest of config
}));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.
Plugin Sign-In Methods
Better Auth client plugins can add sign-in methods under authClient.signIn.
Set signInMethod when the mutation should call one of those methods:
'use client';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useSignInMutationOptions } from '@/lib/convex/auth-client';
function UsernameLoginForm() {
const router = useRouter();
const signIn = useMutation(
useSignInMutationOptions({
signInMethod: 'username',
onSuccess: () => router.push('/'),
})
);
const handleSubmit = (username: string, password: string) => {
signIn.mutate({
callbackURL: window.location.origin,
password,
username,
});
};
}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 'kitcn/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. With optimisticAuth, also true for a held, unexpired JWT before Convex confirms it; the server still enforces auth |
isLoading | Convex auth is still loading |
useMaybeAuth
Check if user maybe has auth (optimistic, has token):
import { useMaybeAuth } from 'kitcn/react';
function Component() {
const isAuth = useMaybeAuth();
return isAuth ? <LoggedInUI /> : <LoginButton />;
}useIsAuth
Check if user is authenticated (server-verified; with optimisticAuth, also during the optimistic window, while the server still enforces auth):
import { useIsAuth } from 'kitcn/react';
function SecureComponent() {
const isAuth = useIsAuth();
return isAuth ? <SensitiveData /> : <Loading />;
}useAuthGuard
Guard mutations that require authentication:
import { useAuthGuard } from 'kitcn/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!');
});
};useConvexAuthRecovery
Use useConvexAuthRecovery() when the outer auth provider still has a valid
session but Convex became unauthenticated after a transient token refresh
failure. recover() replaces the provider-owned auth binding and resolves only
after Convex confirms authentication.
'use client';
import { useConvexAuthRecovery } from 'kitcn/react';
export function ReconnectAuth() {
const { error, recover, status } = useConvexAuthRecovery();
return (
<div>
<button
disabled={status === 'recovering'}
onClick={() => void recover().catch(() => undefined)}
>
{status === 'recovering' ? 'Reconnecting…' : 'Reconnect'}
</button>
{error && <p>{error.message}</p>}
</div>
);
}Concurrent calls share one promise. Pass { timeoutMs } to override the
10-second timeout. Failures reject with ConvexAuthRecoveryError and one of
these codes: AUTH_PROVIDER_LOADING, AUTH_PROVIDER_UNAUTHENTICATED,
AUTH_RECOVERY_CANCELLED, AUTH_RECOVERY_FAILED, or
AUTH_RECOVERY_TIMEOUT. Do not invoke recovery for intentional sign-out.
Conditional Rendering
MaybeAuthenticated
Render children only when has session (optimistic):
import { MaybeAuthenticated } from 'kitcn/react';
function App() {
return (
<MaybeAuthenticated>
<Dashboard />
</MaybeAuthenticated>
);
}Authenticated
Render children only when server-verified (with optimisticAuth, also during the optimistic window):
import { Authenticated } from 'kitcn/react';
function App() {
return (
<Authenticated>
<SensitiveData />
</Authenticated>
);
}MaybeUnauthenticated
Render children only when no session (optimistic):
import { MaybeAuthenticated, MaybeUnauthenticated } from 'kitcn/react';
function App() {
return (
<>
<MaybeAuthenticated>
<Dashboard />
</MaybeAuthenticated>
<MaybeUnauthenticated>
<LoginPage />
</MaybeUnauthenticated>
</>
);
}Unauthenticated
Render children only when not server-verified (waits for loading):
import { Unauthenticated } from 'kitcn/react';
function App() {
return (
<Unauthenticated>
<LoginPage />
</Unauthenticated>
);
}Provider Configuration
Configure auth callbacks in the provider:
import { ConvexAuthProvider } from 'kitcn/auth/client';
function App() {
return (
<ConvexAuthProvider
client={convexClient}
authClient={authClient}
initialToken={serverToken}
optimisticAuth
onTokenIdentityChange={() => window.location.reload()}
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 |
convexQueryClient | ConvexQueryClient? | Shared TanStack Query client bridge |
initialToken | string? | Initial session token (from SSR) |
optimisticAuth | boolean? | Opens auth-bound query gates for a held, unexpired JWT while Convex confirms it. Defaults to false. |
onTokenIdentityChange | () => void | Enables the document identity guard. Called after the client is closed when a token changes JWT sub or sessionId. Reload the document here. |
tokenIdentityBaseline | string | null | (() => string | null) | Optional `sub |
onTokenIdentityAdmitted | (token: string) => void | Called for every decodable token admitted by the identity guard, including cached tokens. Requires onTokenIdentityChange. |
onMutationUnauthorized | () => void | Called when mutation is blocked |
onQueryUnauthorized | ({ queryName }) => void | Called when query is blocked |
Optimistic auth and document identity
optimisticAuth removes the client-side confirmation wait for a held,
unexpired JWT. Convex still processes authentication before queries on the
same connection. An expired, opaque, or refused token never opens the gate.
The window lasts until the Convex client reports its first auth result; after
that, for the client's lifetime, the gate follows Convex's confirmed state.
Use one optimisticAuth setting for every provider over a Convex client
(results reported before an optimistic provider mounts are not seen), and
expect no optimism over a client the TanStack Start loader already
authenticated.
Use onTokenIdentityChange when one document must never send queued work under
a different user or session. The provider compares the JWT sub and
sessionId, refuses a mismatch before caching or forwarding it, closes the
Convex client, and calls the callback. HTTP cRPC requests use the same guarded
token source. What the guard guarantees:
- A JWT of another user or session (whatever its
exp) is never cached, published, or handed to Convex, cRPC HTTP or the TanStack Start loader. Once an identity is established, a JWT without one is refused too, and an opaque session token is only exchanged for a JWT, never handed to Convex. - Admission rechecks the current baselines after
onTokenIdentityAdmittedreturns, before publishing or handing out the token. - When a trip happens (any provider or the Start loader refuses such a
token), every mounted provider hands out no token and publishes
isAuthenticated: false; each one withonTokenIdentityChangecloses its client and calls it once. The trip covers the page in the browser (never the server): providers and clients mounted later start tripped, a guarded provider that mounts or shows again on a tripped page also closes its client and callsonTokenIdentityChangeonce (so a trip that happened while none was mounted still reaches the app). Enabling the guard after a trip also closes the client and calls back once. Sign-in mutations fail withTOKEN_IDENTITY_CHANGEDuntil the reload.
The guard covers the token kitcn supplies, not an Authorization header the
app sets in httpOptions.headers.
For multiple provider mounts in one document, pass the shared identity as
tokenIdentityBaseline. Its format is sub|sessionId. Every admission (the
SSR token, fresh and cached tokens, sign-in tokens, HTTP headers and the Start
loader) holds a token to the page identity (the first one a provider knew),
the provider's own baseline, and the current answer of every mounted
provider's getter. Held tokens are reconciled when a provider joins the page
and at every admission, so a provider that mounts with an SSR token of
another identity trips the page. A page that never enables
onTokenIdentityChange is unchanged. Once a guarded provider establishes the
page identity, it persists until the reload and binds every provider (with or
without the option) and the Start loader, even after that provider unmounts.
Two kitcn versions or revisions on one page (including dev HMR across
revisions) are unsupported: their entries share no page identity until the
reload, and an entry that finds page state it cannot read refuses its guarded
tokens. Use onTokenIdentityAdmitted to observe admitted
tokens and update the shared document identity.
Auth Flow
SSR → client hydration: getToken() reads cookie → prefetch queries with token → pass initialToken to client → ConvexAuthProvider mounts with token → Convex validates JWT → AuthStateSync updates isAuthenticated → HydrationBoundary hydrates prefetched data.
| Concept | Detail |
|---|---|
| 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 | 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 kitcn/react:
import { ConvexProviderWithAuth } from 'kitcn/react';
function App() {
return (
<ConvexProviderWithAuth client={convex} useAuth={useAuthFromConvexDev}>
<YourApp />
</ConvexProviderWithAuth>
);
}This enables skipUnauth queries, useAuth, conditional rendering components,
and useConvexAuthRecovery to work with @convex-dev/auth.