kitcn

Client

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

Setup

Create an auth client with mutation hooks:

src/lib/convex/auth-client.ts
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

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
import { defineAuth } from './generated/auth';

export default defineAuth((ctx) => ({
  emailAndPassword: {
    enabled: true,
  },
  // ... rest of config
}));

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.

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:

src/components/username-login-form.tsx
'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

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 'kitcn/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. With optimisticAuth, also true for a held, unexpired JWT before Convex confirms it; the server still enforces auth
isLoadingConvex 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:

src/components/create-post.tsx
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.

src/components/reconnect-auth.tsx
'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:

src/app.tsx
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

PropTypeDescription
clientConvexReactClientConvex client instance
authClientAuthClientBetter Auth client instance
convexQueryClientConvexQueryClient?Shared TanStack Query client bridge
initialTokenstring?Initial session token (from SSR)
optimisticAuthboolean?Opens auth-bound query gates for a held, unexpired JWT while Convex confirms it. Defaults to false.
onTokenIdentityChange() => voidEnables the document identity guard. Called after the client is closed when a token changes JWT sub or sessionId. Reload the document here.
tokenIdentityBaselinestring | null | (() => string | null)Optional `sub
onTokenIdentityAdmitted(token: string) => voidCalled for every decodable token admitted by the identity guard, including cached tokens. Requires onTokenIdentityChange.
onMutationUnauthorized() => voidCalled when mutation is blocked
onQueryUnauthorized({ queryName }) => voidCalled 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:

  1. 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.
  2. Admission rechecks the current baselines after onTokenIdentityAdmitted returns, before publishing or handing out the token.
  3. 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 with onTokenIdentityChange closes 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 calls onTokenIdentityChange once (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 with TOKEN_IDENTITY_CHANGED until 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.

ConceptDetail
Token flows server → clientVia initialToken prop
Instant hydrationPrefetched queries hydrate immediately — no loading spinner
Defensive isLoadingPrevents UNAUTHORIZED errors during hydration race
Two sources syncBetter 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:

App.tsx
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.

Next Steps

On this page