This guide walks you through setting up authentication with Better Auth and Convex. The @convex-dev/better-auth plugin acts as a "Sync Engine," automatically keeping your user data in sync between the Auth provider and your Convex database.
Install the Better Auth core and the Convex integration.
pnpm add better-auth @convex-dev/better-auth
bun add better-auth @convex-dev/better-auth
npm install better-auth @convex-dev/better-auth
These files are the same regardless of whether you use SSR or SPA mode.
Register the Better Auth component in your Convex configuration.
import betterAuth from '@convex-dev/better-auth/convex.config'
import { defineApp } from 'convex/server'
const app = defineApp()
app.use(betterAuth)
export default app
Create the auth config provider file.
import type { AuthConfig } from 'convex/server'
import { getAuthConfigProvider } from '@convex-dev/better-auth/auth-config'
export default {
providers: [getAuthConfigProvider()]
} satisfies AuthConfig
Add a users table to store user data synced from Better Auth.
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
authId: v.string(),
displayName: v.optional(v.string()),
email: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number()
})
.index('by_auth_id', ['authId'])
.index('by_email', ['email']),
// Your existing tables
tasks: defineTable({
text: v.string(),
isCompleted: v.boolean(),
}),
});
This file configures Better Auth and defines Triggers to sync user data into your users table automatically.
import { createClient, type GenericCtx, type AuthFunctions } from '@convex-dev/better-auth'
import { convex } from '@convex-dev/better-auth/plugins'
import { betterAuth } from 'better-auth'
import type { DataModel } from './_generated/dataModel'
import { components, internal } from './_generated/api'
import authConfig from './auth.config'
// Get URLs from environment
const siteUrl = process.env.SITE_URL! // Public app URL (Better Auth base URL when using the Nuxt proxy)
// Auth functions for triggers
const authFunctions: AuthFunctions = internal.auth
// Create the auth component client with triggers to sync users
export const authComponent = createClient<DataModel>(components.betterAuth, {
authFunctions,
triggers: {
user: {
// Auto-create user in our table when Better Auth creates one
onCreate: async (ctx, doc) => {
const now = Date.now()
await ctx.db.insert('users', {
authId: doc._id,
displayName: doc.name,
email: doc.email,
avatarUrl: doc.image ?? undefined,
createdAt: now,
updatedAt: now
})
},
// Sync name and email changes
onUpdate: async (ctx, newDoc, oldDoc) => {
const nameChanged = newDoc.name !== oldDoc.name
const emailChanged = newDoc.email !== oldDoc.email
const imageChanged = newDoc.image !== oldDoc.image
if (nameChanged || emailChanged || imageChanged) {
const user = await ctx.db
.query('users')
.withIndex('by_auth_id', (q) => q.eq('authId', newDoc._id))
.first()
if (user) {
await ctx.db.patch(user._id, {
...(nameChanged && { displayName: newDoc.name }),
...(emailChanged && { email: newDoc.email }),
...(imageChanged && { avatarUrl: newDoc.image ?? undefined }),
updatedAt: Date.now()
})
}
}
},
// Delete from our table when auth user is deleted
onDelete: async (ctx, doc) => {
const user = await ctx.db
.query('users')
.withIndex('by_auth_id', (q) => q.eq('authId', doc._id))
.first()
if (user) {
await ctx.db.delete(user._id)
}
}
}
}
})
// Factory function to create auth instance per request
export const createAuth = (ctx: GenericCtx<DataModel>) => {
return betterAuth({
baseURL: siteUrl,
database: authComponent.adapter(ctx),
emailAndPassword: {
enabled: true
},
plugins: [
convex({ authConfig })
],
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24 // 1 day
},
trustedOrigins: [siteUrl]
})
}
// Export trigger handlers for the component
export const { onCreate, onUpdate, onDelete } = authComponent.triggersApi()
Expose the Better Auth routes on the Convex HTTP server.
import { httpRouter } from 'convex/server'
import { authComponent, createAuth } from './auth'
const http = httpRouter()
// Register all Better Auth routes (/api/auth/*)
authComponent.registerRoutes(http, createAuth)
export default http
npx convex dev is running to sync your schema and functions.Go to your Convex Dashboard → Settings → Environment Variables and add:
| Variable | Value |
|---|---|
BETTER_AUTH_SECRET | Generate with openssl rand -base64 32 |
SITE_URL | Your frontend URL, e.g., http://localhost:3000 |
Add to your .env.local:
CONVEX_DEPLOYMENT=dev:your-project
CONVEX_URL=https://your-project.convex.cloud
# Your Nuxt app URL (must match SITE_URL in Convex Dashboard)
SITE_URL=http://localhost:3000
# Recommended for localhost-first auth in dev
NUXT_PUBLIC_CONVEX_SITE_URL=https://your-project.convex.site
siteUrl) is automatically derived from CONVEX_URL by replacing .convex.cloud with .convex.site. You only need to set CONVEX_SITE_URL if you use a custom domain./api/auth/*) and point Nuxt to your dev HTTP Actions URL:convex: {
url: process.env.CONVEX_URL,
siteUrl: process.env.NUXT_PUBLIC_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL
}
.convex.site URL in NUXT_PUBLIC_CONVEX_SITE_URL so local development does not depend on production domains.Authentication is enabled by default - just set your Convex URL:
export default defineNuxtConfig({
modules: ['better-convex-nuxt'],
convex: {
url: process.env.CONVEX_URL
// auth: true is the default!
}
})
The module automatically:
useConvexAuth, useAuthClient)/api/auth/*siteUrl from your Convex URL (replacing .convex.cloud with .convex.site)convex: {
url: process.env.CONVEX_URL,
auth: false // Disable auth features
}
siteUrl explicitly:convex: {
url: process.env.CONVEX_URL,
siteUrl: 'https://site.yourdomain.com'
}
/api/auth/**. You can customize this:convex: {
url: process.env.CONVEX_URL,
authRoute: '/custom/auth' // Proxy at /custom/auth/**
}
That's it! The module automatically handles everything based on your rendering mode:
| Mode | What happens |
|---|---|
SSR (ssr: true, default) | Auto-creates /api/auth/* proxy to forward auth requests to Convex |
SPA (ssr: false) | Auth client talks directly to Convex, no proxy needed |
better-convex-nuxtv0.2.11+, the auth proxy follows only canonical host redirects server-side (same path and query, different host, e.g. apex to www). OAuth/login redirects that change path or query are forwarded back to the browser.We'll update the pages from the Basics guide and add authentication. The app/pages/index.vue will become a landing page with sign-in/sign-out, and app/pages/query.vue will be protected.
<script setup lang="ts">
const router = useRouter()
const authClient = useAuthClient()
const { isAuthenticated } = useConvexAuth()
const email = ref('')
const password = ref('')
const error = ref<string | null>(null)
const loading = ref(false)
// Redirect to home if already authenticated
watch(isAuthenticated, (authenticated) => {
if (authenticated) {
router.push('/')
}
}, { immediate: true })
async function handleSignIn() {
if (!authClient) return
error.value = null
loading.value = true
try {
const { error: authError } = await authClient.signIn.email({
email: email.value,
password: password.value,
})
if (authError) {
error.value = authError.message
} else {
router.push('/')
}
} finally {
loading.value = false
}
}
</script>
<template>
<div>
<h1>Sign In</h1>
<form @submit.prevent="handleSignIn">
<div>
<label>
Email
<input v-model="email" type="email" required />
</label>
</div>
<div>
<label>
Password
<input v-model="password" type="password" required />
</label>
</div>
<button type="submit" :disabled="loading">
{{ loading ? 'Signing in...' : 'Sign In' }}
</button>
</form>
<p v-if="error">{{ error }}</p>
<p>
Don't have an account?
<NuxtLink to="/signup">Sign Up</NuxtLink>
</p>
</div>
</template>
<script setup lang="ts">
const router = useRouter()
const authClient = useAuthClient()
const { isAuthenticated } = useConvexAuth()
const name = ref('')
const email = ref('')
const password = ref('')
const error = ref<string | null>(null)
const loading = ref(false)
// Redirect to home if already authenticated
watch(isAuthenticated, (authenticated) => {
if (authenticated) {
router.push('/')
}
}, { immediate: true })
async function handleSignUp() {
if (!authClient) return
error.value = null
loading.value = true
try {
const { error: authError } = await authClient.signUp.email({
name: name.value,
email: email.value,
password: password.value,
})
if (authError) {
error.value = authError.message
} else {
router.push('/')
}
} finally {
loading.value = false
}
}
</script>
<template>
<div>
<h1>Sign Up</h1>
<form @submit.prevent="handleSignUp">
<div>
<label>
Name
<input v-model="name" type="text" required />
</label>
</div>
<div>
<label>
Email
<input v-model="email" type="email" required />
</label>
</div>
<div>
<label>
Password
<input v-model="password" type="password" required />
</label>
</div>
<button type="submit" :disabled="loading">
{{ loading ? 'Signing up...' : 'Sign Up' }}
</button>
</form>
<p v-if="error">{{ error }}</p>
<p>
Already have an account?
<NuxtLink to="/signin">Sign In</NuxtLink>
</p>
</div>
</template>
<script setup lang="ts">
const { isAuthenticated, user, signOut } = useConvexAuth()
async function handleSignOut() {
await signOut()
}
</script>
<template>
<div>
<nav>
<NuxtLink to="/query">Query</NuxtLink>
</nav>
<h1>Home Page</h1>
<div v-if="isAuthenticated">
<p>Welcome, {{ user?.email || user?.name || 'User' }}!</p>
<button @click="handleSignOut">Sign Out</button>
</div>
<div v-else>
<p>You are not signed in.</p>
<NuxtLink to="/signin">Sign In</NuxtLink>
<span> or </span>
<NuxtLink to="/signup">Sign Up</NuxtLink>
</div>
</div>
</template>
Create a route middleware to protect sensitive pages.
export default defineNuxtRouteMiddleware(() => {
const { isAuthenticated, isPending } = useConvexAuth()
// Wait for auth to load
if (isPending.value) {
return
}
// Redirect to login if not authenticated
if (!isAuthenticated.value) {
return navigateTo('/')
}
})
Apply the middleware to pages that require authentication.
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
import { api } from '~~/convex/_generated/api'
const { data: tasks, status, error, refresh } = await useConvexQuery(api.tasks.get, {}, { server: true })
const { mutate, pending, error: mutationError, reset } = useConvexMutation(api.tasks.create)
const taskText = ref('')
async function handleSubmit() {
if (!taskText.value.trim()) return
await mutate({ text: taskText.value })
taskText.value = ''
}
</script>
<template>
<NuxtLink to="/">Home</NuxtLink>
<div>
<form @submit.prevent="handleSubmit">
<input v-model="taskText" type="text" placeholder="Enter task text" :disabled="pending" />
<button type="submit" :disabled="pending || !taskText.trim()">
{{ pending ? 'Creating...' : 'Create Task' }}
</button>
</form>
<div v-if="mutationError">
<p>Something went wrong</p>
<button @click="reset">Dismiss</button>
</div>
<button @click="() => refresh()">Refresh</button>
<p v-if="status === 'pending'">Loading...</p>
<p v-else-if="error">Error: {{ error.message }}</p>
<ul v-else>
<li v-for="task in tasks" :key="task._id">
{{ task.text }}
</li>
</ul>
</div>
</template>
Secure your queries and mutations by checking the user identity. For more advanced access control with roles, see Permissions.
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
export const get = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Not authenticated");
}
return await ctx.db.query("tasks").collect();
},
});
export const create = mutation({
args: {
text: v.string(),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Not authenticated");
}
return await ctx.db.insert("tasks", {
text: args.text,
isCompleted: false,
});
},
});
convex/auth.ts firesusers tableuseConvexAuth() to check login statusctx.auth.getUserIdentity() to secure dataYou now have a full-stack, authenticated, real-time application!
Here is the complete project structure after setting up authentication.
/api/auth/*) is automatically created by the module when auth: true is set. You don't need to create any server routes manually.import { createClient, type GenericCtx, type AuthFunctions } from '@convex-dev/better-auth'
import { convex } from '@convex-dev/better-auth/plugins'
import { betterAuth } from 'better-auth'
import type { DataModel } from './_generated/dataModel'
import { components, internal } from './_generated/api'
import authConfig from './auth.config'
const siteUrl = process.env.SITE_URL! // Public app URL (Better Auth base URL when using the Nuxt proxy)
const authFunctions: AuthFunctions = internal.auth
export const authComponent = createClient<DataModel>(components.betterAuth, {
authFunctions,
triggers: {
user: {
onCreate: async (ctx, doc) => {
const now = Date.now()
await ctx.db.insert('users', {
authId: doc._id,
displayName: doc.name,
email: doc.email,
avatarUrl: doc.image ?? undefined,
createdAt: now,
updatedAt: now
})
},
onUpdate: async (ctx, newDoc, oldDoc) => {
const nameChanged = newDoc.name !== oldDoc.name
const emailChanged = newDoc.email !== oldDoc.email
const imageChanged = newDoc.image !== oldDoc.image
if (nameChanged || emailChanged || imageChanged) {
const user = await ctx.db
.query('users')
.withIndex('by_auth_id', (q) => q.eq('authId', newDoc._id))
.first()
if (user) {
await ctx.db.patch(user._id, {
...(nameChanged && { displayName: newDoc.name }),
...(emailChanged && { email: newDoc.email }),
...(imageChanged && { avatarUrl: newDoc.image ?? undefined }),
updatedAt: Date.now()
})
}
}
},
onDelete: async (ctx, doc) => {
const user = await ctx.db
.query('users')
.withIndex('by_auth_id', (q) => q.eq('authId', doc._id))
.first()
if (user) {
await ctx.db.delete(user._id)
}
}
}
}
})
export const createAuth = (ctx: GenericCtx<DataModel>) => {
return betterAuth({
baseURL: siteUrl,
database: authComponent.adapter(ctx),
emailAndPassword: { enabled: true },
plugins: [convex({ authConfig })],
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24
},
trustedOrigins: [siteUrl]
})
}
export const { onCreate, onUpdate, onDelete } = authComponent.triggersApi()
If you need Better Auth plugins (for example admin) or typed Better Auth additionalFields on the frontend, use a custom Better Auth client composable in addition to useAuthClient().
This is an advanced pattern for:
authClient.admin.listUsers()authClient.useSession() fields from Better Auth user.additionalFieldsconvex/auth.ts)import { createClient, type GenericCtx, type AuthFunctions } from '@convex-dev/better-auth'
import { convex } from '@convex-dev/better-auth/plugins'
import { betterAuth } from 'better-auth'
import { admin } from 'better-auth/plugins'
// ...existing authComponent setup
export const createAuth = (ctx: GenericCtx<DataModel>) => {
return betterAuth({
// ...your existing config
database: authComponent.adapter(ctx),
user: {
additionalFields: {
organizationId: { type: 'string', required: false },
marketingOptIn: { type: 'boolean', required: false }
}
},
plugins: [
convex({ authConfig }),
admin()
]
})
}
export type AppAuth = ReturnType<typeof createAuth>
app/composables/useExtendedAuthClient.ts)import { convexClient } from '@convex-dev/better-auth/client/plugins'
import { createAuthClient } from 'better-auth/vue'
import { adminClient, inferAdditionalFields } from 'better-auth/client/plugins'
import type { AppAuth } from '../../convex/auth'
export function useExtendedAuthClient() {
const authBaseURL = `${window.location.origin}/api/auth`
return createAuthClient({
// Better Auth client expects an absolute URL.
baseURL: authBaseURL,
plugins: [convexClient(), inferAdditionalFields<AppAuth>(), adminClient()],
fetchOptions: { credentials: 'include' }
})
}
<script setup lang="ts">
const authClient = useExtendedAuthClient()
const { signOut } = useConvexAuth()
const session = authClient.useSession()
const orgId = computed(() => session.value.data?.user.organizationId)
async function loadUsers() {
const result = await authClient.admin.listUsers({ query: { limit: 10 } })
console.log(result)
}
async function handleSignOut() {
// Keep Convex auth state reactive
await signOut()
}
</script>
import type { AppAuth } only. Do not runtime-import your server auth instance into client code.For a deeper explanation of Better Auth additionalFields vs Convex JWT claims (useConvexAuth().user) vs your app's Convex users table, see Authentication API & Patterns.
You've built an authenticated app! Here's where to go next: