Better Auth + Convex + TanStack Start Implementation Guide
Better Auth + Convex + TanStack Start Implementation Guide
This guide walks through implementing authentication using Better Auth with Convex as the database and TanStack Start as the React meta-framework. This combination provides:
- Email/password authentication
- Session management via Convex
- Server-side auth middleware for route protection
- Type-safe auth integration with Convex queries/mutations
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ Client Side │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ auth-client │ │ ConvexBetterAuth │ │
│ │ (React) │───▶│ Provider │ │
│ └──────────────┘ └──────────────────┘ │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ TanStack Start SSR │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ auth-middleware │───▶│ auth-server │ │
│ │ (route guards) │ │ (token handling) │ │
│ └──────────────────┘ └──────────────────┘ │
└────────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Convex Backend │
│ ┌──────────────┐ ┌──────────────────┐ ┌───────────────┐ │
│ │ auth.ts │───▶│ Better Auth │───▶│ Convex Tables │ │
│ │ (config) │ │ Component │ │ (user, etc.) │ │
│ └──────────────┘ └──────────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Prerequisites
Before starting, ensure you have:
- A Convex project set up (
npx convex devrunning) - TanStack Start configured
- Node.js 18+ / Bun installed
Step 1: Install Dependencies
Install the required packages:
# Using bun
bun add better-auth @convex-dev/better-auth
# Or using npm
npm install better-auth @convex-dev/better-auth
# Or using pnpm
pnpm add better-auth @convex-dev/better-auth
The key packages:
better-auth- Core authentication library@convex-dev/better-auth- Convex adapter and integration for Better Auth
Step 2: Configure Convex Auth Component
2.1 Create convex/auth.config.ts
This file configures the Convex auth providers:
import { getAuthConfigProvider } from "@convex-dev/better-auth/auth-config";
import type { AuthConfig } from "convex/server";
export default {
providers: [getAuthConfigProvider()],
} satisfies AuthConfig;
2.2 Create convex/auth.ts
This is the main Convex-side auth configuration:
import { createClient, type GenericCtx } from "@convex-dev/better-auth";
import { convex } from "@convex-dev/better-auth/plugins";
import { components } from "./_generated/api";
import { DataModel } from "./_generated/dataModel";
import { query } from "./_generated/server";
import { betterAuth } from "better-auth";
import authConfig from "./auth.config";
// Get the site URL from environment
const siteUrl = process.env.SITE_URL!;
// Create the Convex Better Auth component client
// This provides methods for integrating Convex with Better Auth
export const authComponent = createClient<DataModel>(components.betterAuth);
// Factory function to create an auth instance with the current context
export const createAuth = (ctx: GenericCtx<DataModel>) => {
return betterAuth({
baseURL: siteUrl,
database: authComponent.adapter(ctx),
// Enable email/password authentication
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
},
plugins: [
// The Convex plugin is required for Convex compatibility
convex({ authConfig }),
],
});
};
// Query to get the current authenticated user
export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
return authComponent.getAuthUser(ctx);
},
});
Key points:
authComponentis the Convex client that bridges Better Auth with ConvexcreateAuthcreates a Better Auth instance with Convex as the databaseauthComponent.getAuthUser(ctx)retrieves the current user in any query/mutation
Step 3: Create Client-Side Auth Client
Create src/lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { convexClient } from "@convex-dev/better-auth/client/plugins";
// Determine base URL for auth requests
// On client: use current origin
// On server: use VITE_SITE_URL environment variable
const baseURL = typeof window !== 'undefined'
? window.location.origin
: (import.meta.env.VITE_SITE_URL ?? 'http://localhost:3000');
export const authClient = createAuthClient({
baseURL,
plugins: [convexClient()],
});
Key points:
- Uses
createAuthClientfrombetter-auth/reactfor React integration - The
convexClient()plugin enables Convex-specific auth features baseURLhandles both client and server-side rendering scenarios
Step 4: Create Server-Side Auth Handlers
Create src/lib/auth-server.ts
This file provides server-side auth functions for TanStack Start:
import { convexBetterAuthReactStart } from "@convex-dev/better-auth/react-start";
export const {
handler,
getToken,
fetchAuthQuery,
fetchAuthMutation,
fetchAuthAction,
} = convexBetterAuthReactStart({
convexUrl: import.meta.env.VITE_CONVEX_URL!,
convexSiteUrl: import.meta.env.VITE_CONVEX_SITE_URL!,
});
Exported functions:
handler- HTTP handler for auth API routesgetToken- Get the current auth token (for middleware)fetchAuthQuery- Execute authenticated Convex queries server-sidefetchAuthMutation- Execute authenticated Convex mutations server-sidefetchAuthAction- Execute authenticated Convex actions server-side
Step 5: Create Auth Middleware
Create src/lib/auth-middleware.ts
This provides route guards using TanStack Start's server functions:
import { createMiddleware, createServerFn } from '@tanstack/react-start'
import { redirect } from '@tanstack/react-router'
import { getToken } from './auth-server'
// Middleware that checks authentication status
const authMiddleware = createMiddleware().server(async ({ next }) => {
const token = await getToken()
return next({
context: {
isAuthenticated: !!token,
},
})
})
// Server function to require authentication
// Use in route's beforeLoad to protect routes
export const requireAuth = createServerFn({ method: 'GET' })
.middleware([authMiddleware])
.handler(async ({ context }) => {
if (!context.isAuthenticated) {
throw redirect({ to: '/login' })
}
return {}
})
// Server function for guest-only routes (login, register)
// Redirects authenticated users away
export const guestOnly = createServerFn({ method: 'GET' })
.middleware([authMiddleware])
.handler(async ({ context }) => {
if (context.isAuthenticated) {
throw redirect({ to: '/' })
}
return {}
})
Usage patterns:
requireAuth()- Protects routes that need authenticationguestOnly()- Prevents authenticated users from accessing login/register
Step 6: Set Up the Convex Provider
Create src/integrations/convex/provider.tsx
import { ConvexProvider } from 'convex/react'
import { ConvexQueryClient } from '@convex-dev/react-query'
import { useState, useEffect } from 'react'
import { ConvexBetterAuthProvider } from '@convex-dev/better-auth/react'
import { authClient } from '@/lib/auth-client'
const CONVEX_URL = (import.meta as any).env.VITE_CONVEX_URL
// Create client only on client-side to avoid SSR issues
let convexQueryClient: ConvexQueryClient | null = null
function getConvexQueryClient() {
if (typeof window === 'undefined') {
return null
}
if (!convexQueryClient) {
if (!CONVEX_URL) {
console.error('missing envar VITE_CONVEX_URL')
return null
}
convexQueryClient = new ConvexQueryClient(CONVEX_URL)
}
return convexQueryClient
}
export default function AppConvexProvider(props: {
children: React.ReactNode
initialToken?: string | null
}) {
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
// During SSR or before hydration, render children without Convex
if (!isClient) {
return <>{props.children}</>
}
const client = getConvexQueryClient()
if (!client) {
return <>{props.children}</>
}
return (
<ConvexBetterAuthProvider
client={client.convexClient}
authClient={authClient}
initialToken={props.initialToken}
>
{props.children}
</ConvexBetterAuthProvider>
)
}
Key points:
- Uses
ConvexBetterAuthProviderinstead of plainConvexProvider - Handles SSR by delaying Convex client creation until client-side
- Passes
authClientto connect authentication with Convex
Step 7: Integrate Provider in Root Route
Update src/routes/__root.tsx
import {
HeadContent,
Outlet,
Scripts,
createRootRoute,
} from '@tanstack/react-router'
import ConvexProvider from '../integrations/convex/provider'
import appCss from '../styles.css?url'
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'Your App Name' },
],
links: [
{ rel: 'stylesheet', href: appCss },
],
}),
component: RootComponent,
shellComponent: RootDocument,
})
function RootComponent() {
return (
<ConvexProvider>
<Outlet />
</ConvexProvider>
)
}
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
)
}
Step 8: Create Authentication Pages
8.1 Login Page (src/routes/login.tsx)
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import { authClient } from '@/lib/auth-client'
import { guestOnly } from '@/lib/auth-middleware'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
export const Route = createFileRoute('/login')({
// Prevent authenticated users from accessing this page
beforeLoad: () => guestOnly(),
component: LoginPage,
})
function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsLoading(true)
try {
const result = await authClient.signIn.email({
email,
password,
})
if (result.error) {
setError(result.error.message ?? 'Sign in failed')
} else {
// Full page reload to refresh auth state everywhere
window.location.href = '/'
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
setIsLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Welcome back</CardTitle>
<CardDescription>
Sign in to your account to continue
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
Password
</label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign in'}
</Button>
<p className="text-center text-sm text-muted-foreground">
Don't have an account?{' '}
<Link
to="/register"
className="font-medium text-primary hover:underline"
>
Sign up
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
)
}
8.2 Register Page (src/routes/register.tsx)
import { createFileRoute, Link } from '@tanstack/react-router'
import { useState } from 'react'
import { authClient } from '@/lib/auth-client'
import { guestOnly } from '@/lib/auth-middleware'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
export const Route = createFileRoute('/register')({
beforeLoad: () => guestOnly(),
component: RegisterPage,
})
function RegisterPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [name, setName] = useState('')
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
if (password.length < 8) {
setError('Password must be at least 8 characters')
return
}
setIsLoading(true)
try {
const result = await authClient.signUp.email({
email,
password,
name,
})
if (result.error) {
setError(result.error.message ?? 'Registration failed')
} else {
window.location.href = '/'
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
setIsLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Create an account</CardTitle>
<CardDescription>
Enter your details to get started
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium">
Name
</label>
<Input
id="name"
type="text"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
Password
</label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label htmlFor="confirmPassword" className="text-sm font-medium">
Confirm Password
</label>
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
disabled={isLoading}
/>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Creating account...' : 'Create account'}
</Button>
<p className="text-center text-sm text-muted-foreground">
Already have an account?{' '}
<Link
to="/login"
className="font-medium text-primary hover:underline"
>
Sign in
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
)
}
Step 9: Protect Routes
Example Protected Route (src/routes/index.tsx)
import { createFileRoute } from '@tanstack/react-router'
import { requireAuth } from '@/lib/auth-middleware'
export const Route = createFileRoute('/')({
// This runs on the server before the route loads
// Redirects to /login if not authenticated
beforeLoad: () => requireAuth(),
component: HomePage,
})
function HomePage() {
return (
<div>
<h1>Welcome! You are authenticated.</h1>
</div>
)
}
Step 10: Use Auth in Convex Functions
Pattern for Authenticated Queries/Mutations
import { v } from 'convex/values'
import { mutation, query } from './_generated/server'
import type { QueryCtx, MutationCtx } from './_generated/server'
import { authComponent } from './auth'
// Reusable helper to get authenticated user
async function getAuthenticatedUser(ctx: QueryCtx | MutationCtx) {
const user = await authComponent.getAuthUser(ctx)
if (!user) {
throw new Error('Not authenticated')
}
return user
}
// Example query with authentication
export const listItems = query({
handler: async (ctx) => {
const user = await getAuthenticatedUser(ctx)
return await ctx.db
.query('items')
.withIndex('by_user', (q) => q.eq('userId', user.id))
.collect()
},
})
// Example mutation with authentication
export const addItem = mutation({
args: {
name: v.string(),
},
handler: async (ctx, args) => {
const user = await getAuthenticatedUser(ctx)
return await ctx.db.insert('items', {
userId: user.id,
name: args.name,
})
},
})
// Example: Ensure user owns the resource before modifying
export const deleteItem = mutation({
args: {
id: v.id('items'),
},
handler: async (ctx, args) => {
const user = await getAuthenticatedUser(ctx)
const item = await ctx.db.get(args.id)
// Ownership check
if (!item || item.userId !== user.id) {
throw new Error('Item not found')
}
await ctx.db.delete(args.id)
},
})
Step 11: Environment Variables
Required Environment Variables
Create a .env file (or configure in your deployment):
# Convex
VITE_CONVEX_URL=https://your-project.convex.cloud
VITE_CONVEX_SITE_URL=https://your-project.convex.site
# Site URL (for auth callbacks)
VITE_SITE_URL=http://localhost:3000 # or your production URL
SITE_URL=http://localhost:3000 # Server-side (Convex functions)
Note:
VITE_*prefixed variables are exposed to the clientSITE_URL(without VITE_) is for server-side Convex functions
Step 12: Implementing Sign Out
Add a sign-out button in your app:
import { authClient } from '@/lib/auth-client'
function SignOutButton() {
const handleSignOut = async () => {
await authClient.signOut()
window.location.href = '/login'
}
return (
<button onClick={handleSignOut}>
Sign Out
</button>
)
}
Step 13: Accessing User in Components
Using the useSession Hook
import { authClient } from '@/lib/auth-client'
function UserProfile() {
const session = authClient.useSession()
if (session.isPending) {
return <div>Loading...</div>
}
if (!session.data) {
return <div>Not logged in</div>
}
return (
<div>
<p>Welcome, {session.data.user.name}!</p>
<p>Email: {session.data.user.email}</p>
</div>
)
}
Using the Convex Query
import { useQuery } from 'convex/react'
import { api } from '../../convex/_generated/api'
function UserInfo() {
const user = useQuery(api.auth.getCurrentUser)
if (user === undefined) {
return <div>Loading...</div>
}
if (!user) {
return <div>Not authenticated</div>
}
return (
<div>
<p>User ID: {user.id}</p>
<p>Email: {user.email}</p>
</div>
)
}
Database Schema
Better Auth with Convex automatically manages these tables:
user- User recordssession- Active sessionsaccount- OAuth accounts (if using OAuth)verification- Email verification tokens
Your application tables should reference users by their Better Auth user ID (string):
// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'
export default defineSchema({
// Your application tables
items: defineTable({
userId: v.string(), // Better Auth user ID
name: v.string(),
}).index('by_user', ['userId']),
})
Additional Features
Adding OAuth Providers
To add OAuth (Google, GitHub, etc.), update convex/auth.ts:
export const createAuth = (ctx: GenericCtx<DataModel>) => {
return betterAuth({
baseURL: siteUrl,
database: authComponent.adapter(ctx),
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
},
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
plugins: [
convex({ authConfig }),
],
});
};
Email Verification
Enable email verification:
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendVerificationEmail: async ({ user, url }) => {
// Send verification email using your email provider
await sendEmail({
to: user.email,
subject: 'Verify your email',
html: `<a href="${url}">Verify your email</a>`,
});
},
},
Troubleshooting
Common Issues
"Not authenticated" errors in Convex
- Ensure
ConvexBetterAuthProviderwraps your app - Check that
authClientis properly configured
- Ensure
SSR hydration mismatches
- The provider handles SSR by rendering children without Convex during SSR
- Ensure client-only code is wrapped in
useEffector client components
Redirect loops
- Verify
requireAuthandguestOnlyare used on correct routes - Check that token is being properly retrieved
- Verify
Environment variables not loading
- Ensure
VITE_prefix for client-side variables - Restart the dev server after changing
.env
- Ensure
File Structure Summary
├── convex/
│ ├── auth.config.ts # Convex auth provider config
│ ├── auth.ts # Better Auth setup with Convex adapter
│ └── schema.ts # Your database schema
├── src/
│ ├── integrations/
│ │ └── convex/
│ │ └── provider.tsx # ConvexBetterAuthProvider wrapper
│ ├── lib/
│ │ ├── auth-client.ts # Client-side auth client
│ │ ├── auth-server.ts # Server-side auth utilities
│ │ └── auth-middleware.ts # Route protection middleware
│ └── routes/
│ ├── __root.tsx # Root layout with provider
│ ├── login.tsx # Login page
│ ├── register.tsx # Registration page
│ └── index.tsx # Protected home page
└── .env # Environment variables
Summary
This implementation provides:
- Email/password authentication via Better Auth
- Convex as the database for user data and sessions
- Server-side route protection via TanStack Start middleware
- Type-safe integration throughout the stack
- SSR compatibility with proper hydration handling
The key integration points are:
authComponent.getAuthUser(ctx)in Convex functionsauthClient.signIn.email()/authClient.signUp.email()on the clientrequireAuth()/guestOnly()in routebeforeLoad