Authentication
What We Did
Section titled “What We Did”Built the authentication UI layer on top of Better Auth, including:
- Login/signup forms using TanStack Form and shadcn Field components
- Protected page that requires authentication
- User-owned “things” with CRUD operations
- Session-aware UI with sign out functionality
Why This Approach
Section titled “Why This Approach”Key reasons:
- TanStack Form: Headless form library with excellent TypeScript support and validation
- Field components: Consistent form styling using existing shadcn/ui Field primitives
- Client-side auth check: Better Auth’s
useSessionhook for reactive auth state - User ownership pattern: Simple
userIdfield on data for multi-tenant isolation
Alternatives considered:
- React Hook Form: More popular but TanStack Form has better TypeScript inference
- Server-side protection: Could use middleware, but client-side is simpler for this demo
- Separate login/signup pages: Combined form is more compact for simple auth flows
Commands Used
Section titled “Commands Used”# Install TanStack Formbun add @tanstack/react-form --filter=web
# Clear old data without userId fieldbunx convex run things:deleteAllThings
# Push schema changesbunx convex dev --onceImplementation Details
Section titled “Implementation Details”Schema Changes
Section titled “Schema Changes”Updated packages/backend/convex/schema.ts to add user ownership:
export default defineSchema({ things: defineTable({ title: v.string(), userId: v.string(), }).index("by_user", ["userId"]),})The by_user index enables efficient queries for a user’s things.
Protected Mutations
Section titled “Protected Mutations”All CRUD operations in packages/backend/convex/things.ts now require authentication:
import { authComponent } from "./auth"
export const createThing = mutation({ args: { title: v.string() }, handler: async (ctx, args) => { const user = await authComponent.getAuthUser(ctx) if (!user) { throw new Error("Not authenticated") } const userId = user._id as string return await ctx.db.insert("things", { title: args.title, userId, }) },})
export const getThings = query({ args: {}, handler: async (ctx) => { const user = await authComponent.getAuthUser(ctx) if (!user) { return [] } const userId = user._id as string return await ctx.db .query("things") .withIndex("by_user", (q) => q.eq("userId", userId)) .collect() },})Auth Form Component
Section titled “Auth Form Component”Created apps/web/components/auth-form.tsx using TanStack Form:
"use client"
import { useForm } from "@tanstack/react-form"import { authClient } from "@/lib/auth-client"import { Field, FieldError, FieldGroup, FieldLabel } from "@repo/ui/components/ui/field"import { Input } from "@repo/ui/components/ui/input"import { Button } from "@repo/ui/components/ui/button"
export function AuthForm() { const [mode, setMode] = useState<"login" | "signup">("login")
const form = useForm({ defaultValues: { email: "", password: "", name: "" }, onSubmit: async ({ value }) => { if (mode === "signup") { await authClient.signUp.email({ email: value.email, password: value.password, name: value.name, }) } else { await authClient.signIn.email({ email: value.email, password: value.password, }) } }, })
return ( <form onSubmit={(e) => { e.preventDefault(); form.handleSubmit() }}> <FieldGroup> <form.Field name="email" validators={{ onChange: ({ value }) => { if (!value) return "Email is required" if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Invalid email" } }}> {(field) => ( <Field> <FieldLabel>Email</FieldLabel> <Input type="email" value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} /> {field.state.meta.errors.length > 0 && ( <FieldError>{field.state.meta.errors.join(", ")}</FieldError> )} </Field> )} </form.Field> {/* Password field similar pattern */} <Button type="submit"> {mode === "login" ? "Sign in" : "Create account"} </Button> </FieldGroup> </form> )}Protected Page
Section titled “Protected Page”Updated apps/web/app/page.tsx to check auth state:
"use client"
import { authClient } from "@/lib/auth-client"import { AuthForm } from "@/components/auth-form"
export default function Home() { const { data: session, isPending } = authClient.useSession()
if (isPending) { return <div>Loading...</div> }
if (!session) { return <AuthForm /> }
return ( <div> <header> <span>{session.user?.email}</span> <Button onClick={() => authClient.signOut()}>Sign out</Button> </header> <ThingsManager /> </div> )}TypeScript Path Alias
Section titled “TypeScript Path Alias”Added @/* path alias in apps/web/tsconfig.json:
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./*"] } }}Key Dependencies
Section titled “Key Dependencies”apps/web:
@tanstack/react-form: ^1.27.7 - Headless form state management
Integration with Existing Code
Section titled “Integration with Existing Code”- Better Auth: Uses
authClientfrom@/lib/auth-clientfor sign in/up/out - Convex: Uses
authComponent.getAuthUser(ctx)to get current user in backend - shadcn/ui: Uses Field, Input, Button, Card components for consistent styling
Context for AI
Section titled “Context for AI”When working with authentication in this codebase:
- Get current user in Convex:
const user = await authComponent.getAuthUser(ctx) - User ID is
user._id: Cast to string withuser._id as string - Check session on client:
const { data: session } = authClient.useSession() - Sign out:
await authClient.signOut() - Form validation: Use TanStack Form’s
validators.onChangefor field-level validation
Adding Auth to New Tables
Section titled “Adding Auth to New Tables”- Add
userId: v.string()to schema - Add
.index("by_user", ["userId"])for efficient queries - In mutations: check
authComponent.getAuthUser(ctx)and throw if null - In queries: filter by
userIdusing the index
Data Migration Pattern
Section titled “Data Migration Pattern”When adding required fields to existing data:
// 1. Make field optional temporarilyuserId: v.optional(v.string())
// 2. Deploy and run migrationbunx convex dev --oncebunx convex run tableName:migrateOrDelete
// 3. Make field requireduserId: v.string()
// 4. Deploy final schemabunx convex dev --onceOutcomes
Section titled “Outcomes”Before
Section titled “Before”- Things table had no user association
- Anyone could see all things
- No authentication UI
- Things belong to users via
userIdfield - Users only see their own things
- Login/signup forms with validation
- Sign out functionality
- Protected page redirects to auth form
Testing/Verification
Section titled “Testing/Verification”# Start dev serverturbo dev --filter=web- Visit http://localhost:3000
- See login form (not authenticated)
- Click “Sign up” and create account
- After signup, see Things Manager
- Create a thing - it’s associated with your user
- Sign out and sign in with different account
- New account sees empty things list (isolation works)
Next Steps
Section titled “Next Steps”- Add password reset flow
- Add OAuth providers (Google, GitHub)
- Add email verification
- Add user profile page
- Add sharing/collaboration features
Related Documentation
Section titled “Related Documentation”- Better Auth Integration - Backend auth setup
- shadcn/ui Components - UI component library
- TanStack Form Documentation
- Better Auth React Reference