← back home

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 dev running)
  • 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:

  • authComponent is the Convex client that bridges Better Auth with Convex
  • createAuth creates a Better Auth instance with Convex as the database
  • authComponent.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 createAuthClient from better-auth/react for React integration
  • The convexClient() plugin enables Convex-specific auth features
  • baseURL handles 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 routes
  • getToken - Get the current auth token (for middleware)
  • fetchAuthQuery - Execute authenticated Convex queries server-side
  • fetchAuthMutation - Execute authenticated Convex mutations server-side
  • fetchAuthAction - 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 authentication
  • guestOnly() - 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 ConvexBetterAuthProvider instead of plain ConvexProvider
  • Handles SSR by delaying Convex client creation until client-side
  • Passes authClient to 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 client
  • SITE_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 records
  • session - Active sessions
  • account - 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

  1. "Not authenticated" errors in Convex

    • Ensure ConvexBetterAuthProvider wraps your app
    • Check that authClient is properly configured
  2. SSR hydration mismatches

    • The provider handles SSR by rendering children without Convex during SSR
    • Ensure client-only code is wrapped in useEffect or client components
  3. Redirect loops

    • Verify requireAuth and guestOnly are used on correct routes
    • Check that token is being properly retrieved
  4. Environment variables not loading

    • Ensure VITE_ prefix for client-side variables
    • Restart the dev server after changing .env

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:

  1. Email/password authentication via Better Auth
  2. Convex as the database for user data and sessions
  3. Server-side route protection via TanStack Start middleware
  4. Type-safe integration throughout the stack
  5. SSR compatibility with proper hydration handling

The key integration points are:

  • authComponent.getAuthUser(ctx) in Convex functions
  • authClient.signIn.email() / authClient.signUp.email() on the client
  • requireAuth() / guestOnly() in route beforeLoad