| Composable | Purpose | Use when... |
|---|---|---|
useConvexAuth() | Reactive auth state + auth actions | Default choice. Sign in, sign up, sign out, and read auth state. |
useAuthClient() / custom client | Raw Better Auth client | Plugin-specific APIs (admin, organization, etc.). |
useConvexAuth() is the canonical public composable for auth state and common auth actions in this module.
useConvexAuth)<script setup lang="ts">
const { isAuthenticated, user, isPending } = useConvexAuth()
</script>
<template>
<div v-if="isPending">Checking auth...</div>
<div v-else-if="isAuthenticated">Welcome, {{ user?.name }}!</div>
<div v-else>
<NuxtLink to="/auth/signin">Sign In</NuxtLink>
</div>
</template>
Authentication is pre-populated during SSR for instant authenticated rendering:
flowchart LR
A["Browser Cookie"] --> B["Nuxt SSR plugin"]
B --> C["Convex JWT exchange"]
C --> D["HTML + hydrated auth state"]
D --> E["Client hydration (no flash)"]
E --> F["Convex WebSocket starts"]
Key benefit: Users see authenticated content immediately, no flash of unauthenticated state.
When running with ssr: false in your Nuxt config, the module automatically detects this and fetches auth state client-side:
1. Browser loads page (no SSR)
|
v
2. Client Plugin
- Detects CSR-only mode
- Fetches token from /api/auth/convex/token
- Extracts user from JWT
- Initializes ConvexClient with token
Recommended default (most apps): only set CONVEX_URL.
CONVEX_URL=https://your-dev-deployment.convex.cloud
SITE_URL=http://localhost:3000
The module automatically derives convex.siteUrl (*.convex.site) from CONVEX_URL.
convex.siteUrl = Convex HTTP Actions host (used by the Nuxt auth proxy and SSR token exchange)SITE_URL = your app origin used by Better Auth inside convex/auth.ts (important for redirects/OAuth)
::convex.siteUrl explicitly:export default defineNuxtConfig({
modules: ['better-convex-nuxt'],
convex: {
url: process.env.CONVEX_URL,
siteUrl: 'https://api.example.com',
},
})
/api/auth/*) and point the module at your dev Convex HTTP Actions URL.export default defineNuxtConfig({
modules: ['better-convex-nuxt'],
convex: {
url: process.env.CONVEX_URL,
siteUrl: process.env.CONVEX_SITE_URL,
trustedOrigins: ['https://preview-*.vercel.app'],
},
})
CONVEX_URL=https://your-dev-deployment.convex.cloud
CONVEX_SITE_URL=https://your-dev-deployment.convex.site
SITE_URL=http://localhost:3000
better-convex-nuxtv0.2.11+ follows only canonical host redirects server-side (same path/query, different host) and forwards OAuth redirects to the browser./api/auth/*), set convex.siteUrl in nuxt.config to your Convex HTTP Actions host (*.convex.site), but set Better Auth baseURL in convex/auth.ts to your app URL (SITE_URL). Redirect handling improved in v0.2.11+, but social OAuth callbacks still depend on the correct baseURL.baseURL to convex.siteUrl / CONVEX_SITE_URL.baseURL should be your app origin (SITE_URL), while convex.siteUrl should point to Convex HTTP Actions.useConvexAuth() (Primary API)useConvexAuth() for both reactive auth state and standard auth actions.<script setup lang="ts">
const { signIn, signOut, isAuthenticated, user } = useConvexAuth()
async function signInWithEmail(email: string, password: string) {
const { data, error } = await signIn.email({
email,
password,
})
if (error) {
console.error('Sign in failed:', error.message)
} else {
navigateTo('/dashboard')
}
}
async function handleSignOut() {
await signOut()
navigateTo('/')
}
</script>
signIn / signUp are client-only (safe to reference during SSR; they warn if called on the server)client is available on useConvexAuth() as an advanced escape hatch for plugin-specific Better Auth APIs
::useConvexAuth().useruseConvexAuth().signIn, useConvexAuth().signUpuseConvexAuth().signOutadmin, organization, ...): useConvexAuth().client or a custom typed client
::useConvexAuth().client returns the module-managed Better Auth client used by better-convex-nuxt.
It is ideal for standard sign-in/sign-up flows, but it is not plugin-typed for arbitrary Better Auth plugins (for example admin, organization, etc.).If you need plugin-specific client methods like authClient.admin.listUsers(), create your own client instance on the frontend and include:convexClient() to preserve Convex token syncadminClient())convex/auth.tsimport { convex } from '@convex-dev/better-auth/plugins'
import { admin } from 'better-auth/plugins'
plugins: [convex({ authConfig }), admin()]
import { convexClient } from '@convex-dev/better-auth/client/plugins'
import { createAuthClient } from 'better-auth/vue'
import { adminClient } from 'better-auth/client/plugins'
export function useExtendedAuthClient() {
const authBaseURL = `${window.location.origin}/api/auth`
return createAuthClient({
// If you use the Nuxt auth proxy, keep this same-origin.
// Better Auth client expects an absolute URL.
baseURL: authBaseURL,
plugins: [convexClient(), adminClient()],
fetchOptions: { credentials: 'include' },
})
}
<script setup lang="ts">
const authClient = useExtendedAuthClient()
const { signOut } = useConvexAuth()
async function loadUsers() {
const result = await authClient.admin.listUsers({
query: { limit: 10 },
})
console.log(result)
}
async function handleSignOut() {
// Prefer Convex-aware logout so local auth state updates immediately.
await signOut()
navigateTo('/')
}
</script>
authClient.signOut() directly in app code and expecting useConvexAuth().isAuthenticated to update immediately.useConvexAuth().signOut() so Better Auth logout and Convex-local auth state stay in sync in the same tab.<script setup lang="ts">
const { signIn } = useConvexAuth()
const email = ref('')
const password = ref('')
const error = ref<string | null>(null)
const loading = ref(false)
async function handleSignIn() {
error.value = null
loading.value = true
try {
const { error: authError } = await signIn.email({
email: email.value,
password: password.value,
})
if (authError) {
error.value = authError.message
} else {
navigateTo('/dashboard')
}
} finally {
loading.value = false
}
}
</script>
<template>
<form @submit.prevent="handleSignIn">
<input v-model="email" type="email" placeholder="Email" />
<input v-model="password" type="password" placeholder="Password" />
<button :disabled="loading">
{{ loading ? 'Signing in...' : 'Sign In' }}
</button>
<p v-if="error" class="error">{{ error }}</p>
</form>
</template>
<script setup lang="ts">
const { signIn } = useConvexAuth()
async function signInWithGoogle() {
await signIn.social({
provider: 'google',
callbackURL: '/dashboard',
})
}
async function signInWithGitHub() {
await signIn.social({
provider: 'github',
callbackURL: '/dashboard',
})
}
</script>
<template>
<div class="oauth-buttons">
<button @click="signInWithGoogle">Continue with Google</button>
<button @click="signInWithGitHub">Continue with GitHub</button>
</div>
</template>
<script setup lang="ts">
const { signUp } = useConvexAuth()
const name = ref('')
const email = ref('')
const password = ref('')
const error = ref<string | null>(null)
async function handleSignUp() {
const { error: authError } = await signUp.email({
name: name.value,
email: email.value,
password: password.value,
})
if (authError) {
error.value = authError.message
} else {
navigateTo('/dashboard')
}
}
</script>
<template>
<form @submit.prevent="handleSignUp">
<input v-model="name" placeholder="Name" />
<input v-model="email" type="email" placeholder="Email" />
<input v-model="password" type="password" placeholder="Password" />
<button>Create Account</button>
<p v-if="error" class="error">{{ error }}</p>
</form>
</template>
<script setup lang="ts">
const { isAuthenticated, user, signOut } = useConvexAuth()
async function handleSignOut() {
await signOut()
navigateTo('/')
}
</script>
<template>
<div v-if="isAuthenticated" class="user-menu">
<span>{{ user?.name }}</span>
<button @click="handleSignOut">Sign Out</button>
</div>
</template>
| Component | Shows content when... |
|---|---|
<ConvexAuthLoading> | Auth state is being determined |
<ConvexAuthenticated> | User is authenticated |
<ConvexUnauthenticated> | User is NOT authenticated |
<ConvexAuthError> | Auth error occurred (401/403 or token decode failure) |
<template>
<ConvexAuthLoading>
<div class="loading">Checking authentication...</div>
</ConvexAuthLoading>
<ConvexAuthenticated>
<Dashboard />
</ConvexAuthenticated>
<ConvexUnauthenticated>
<LoginPrompt />
</ConvexUnauthenticated>
</template>
<template>
<ConvexAuthLoading>
<div class="auth-loading">
<Spinner />
<p>Loading...</p>
</div>
</ConvexAuthLoading>
</template>
<template>
<ConvexAuthenticated>
<nav class="user-nav">
<NuxtLink to="/dashboard">Dashboard</NuxtLink>
<NuxtLink to="/settings">Settings</NuxtLink>
<UserMenu />
</nav>
</ConvexAuthenticated>
</template>
<template>
<ConvexUnauthenticated>
<div class="guest-nav">
<NuxtLink to="/auth/signin">Sign In</NuxtLink>
<NuxtLink to="/auth/signup">Sign Up</NuxtLink>
</div>
</ConvexUnauthenticated>
</template>
retry and error slot props.<template>
<ConvexAuthError v-slot="{ retry, error }">
<div class="auth-error">
<p>{{ error || 'Authentication failed. Please try again.' }}</p>
<button @click="retry">Retry</button>
</div>
</ConvexAuthError>
</template>
<template>
<header class="app-header">
<NuxtLink to="/" class="logo">MyApp</NuxtLink>
<nav>
<ConvexAuthLoading>
<div class="skeleton-avatar" />
</ConvexAuthLoading>
<ConvexAuthenticated>
<NuxtLink to="/dashboard">Dashboard</NuxtLink>
<UserDropdown />
</ConvexAuthenticated>
<ConvexUnauthenticated>
<NuxtLink to="/auth/signin">Sign In</NuxtLink>
<NuxtLink to="/auth/signup" class="btn-primary"> Get Started </NuxtLink>
</ConvexUnauthenticated>
</nav>
</header>
</template>
<template>
<div class="dashboard-layout">
<ConvexAuthLoading>
<div class="loading-screen">
<Spinner size="lg" />
<p>Loading your dashboard...</p>
</div>
</ConvexAuthLoading>
<ConvexAuthenticated>
<DashboardSidebar />
<main class="dashboard-content">
<slot />
</main>
</ConvexAuthenticated>
<ConvexUnauthenticated>
<div class="auth-required">
<h1>Authentication Required</h1>
<p>Please sign in to access this page.</p>
<NuxtLink to="/auth/signin" class="btn">Sign In</NuxtLink>
</div>
</ConvexUnauthenticated>
</div>
</template>
<script setup lang="ts">
const { isAuthenticated, isPending } = useConvexAuth()
// Redirect if not authenticated
watch(
() => ({
isAuthenticated: isAuthenticated.value,
isPending: isPending.value,
}),
({ isAuthenticated, isPending }) => {
if (!isPending && !isAuthenticated) {
navigateTo('/auth/signin')
}
},
{ immediate: true },
)
</script>
<template>
<div v-if="isPending">Loading...</div>
<div v-else-if="isAuthenticated">
<Dashboard />
</div>
</template>
useConvexAuth() for simple "signed in / signed out" guards in route middleware.export default defineNuxtRouteMiddleware(() => {
const { isAuthenticated, isPending } = useConvexAuth()
if (isPending.value) return
if (!isAuthenticated.value) {
return navigateTo('/auth/signin')
}
})
useConvexCallserverConvexQueryimport { api } from '~~/convex/_generated/api'
export default defineNuxtRouteMiddleware(async () => {
const context = import.meta.server
? await serverConvexQuery(api.auth.getPermissionContext, {})
: await useConvexCall({ timeoutMs: 5000 }).query(api.auth.getPermissionContext, {})
if (!context || context.role !== 'admin') {
return navigateTo('/')
}
})
useConvexQuery and useConvexPaginatedQuery are setup-scope composables and should not be used in route middleware/plugins.<script setup lang="ts">
const { isAuthenticated } = useConvexAuth()
// Only fetch when authenticated
const { data: profile } = await useConvexQuery(
api.users.getProfile,
computed(() => (isAuthenticated.value ? {} : 'skip')),
)
</script>
<template>
<div class="comments-section">
<h2>Comments</h2>
<!-- Anyone can read comments -->
<CommentList :postId="postId" />
<!-- Only authenticated users can post -->
<ConvexAuthenticated>
<CommentForm :postId="postId" />
</ConvexAuthenticated>
<ConvexUnauthenticated>
<p class="signin-prompt">
<NuxtLink to="/auth/signin">Sign in</NuxtLink>
to leave a comment.
</p>
</ConvexUnauthenticated>
</div>
</template>
<ClientOnly>:<template>
<ClientOnly>
<ConvexAuthLoading>
<SkeletonHeader />
</ConvexAuthLoading>
<ConvexAuthenticated>
<AuthenticatedHeader />
</ConvexAuthenticated>
<ConvexUnauthenticated>
<GuestHeader />
</ConvexUnauthenticated>
<template #fallback>
<SkeletonHeader />
</template>
</ClientOnly>
</template>
<script setup lang="ts">
const { user } = useConvexAuth()
</script>
<template>
<ConvexAuthenticated>
<div class="welcome">
<img v-if="user?.image" :src="user.image" :alt="user.name" class="avatar" />
<div>
<p class="greeting">Welcome back,</p>
<p class="name">{{ user?.name }}</p>
</div>
</div>
</ConvexAuthenticated>
</template>
useConvexAuth():<!-- Using components -->
<template>
<ConvexAuthenticated>
<Dashboard />
</ConvexAuthenticated>
</template>
<!-- Equivalent with composable -->
<script setup>
const { isAuthenticated, isPending } = useConvexAuth()
</script>
<template>
<Dashboard v-if="!isPending && isAuthenticated" />
</template>
user or token<script setup lang="ts">
const { signIn, isAuthenticated, refreshAuth } = useConvexAuth()
// Redirect if already authenticated
watch(
isAuthenticated,
(value) => {
if (value) navigateTo('/dashboard')
},
{ immediate: true },
)
const email = ref('')
const password = ref('')
const error = ref<string | null>(null)
const loading = ref(false)
async function handleEmailSignIn() {
error.value = null
loading.value = true
try {
const { error: authError } = await signIn.email({
email: email.value,
password: password.value,
})
if (authError) {
error.value = authError.message
} else {
await refreshAuth()
}
} finally {
loading.value = false
}
}
async function handleGoogleSignIn() {
await signIn.social({
provider: 'google',
callbackURL: '/dashboard',
})
}
</script>
<template>
<div class="auth-page">
<h1>Sign In</h1>
<form @submit.prevent="handleEmailSignIn">
<input v-model="email" type="email" placeholder="Email" required />
<input v-model="password" type="password" placeholder="Password" required />
<button type="submit" :disabled="loading">
{{ loading ? 'Signing in...' : 'Sign In' }}
</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<div class="divider">or</div>
<button @click="handleGoogleSignIn" class="oauth-btn">Continue with Google</button>
<p class="signup-link">
Don't have an account?
<NuxtLink to="/auth/signup">Sign Up</NuxtLink>
</p>
</div>
</template>
| Property | Type | Description |
|---|---|---|
token | Readonly<Ref<string | null>> | JWT token for Convex auth |
user | Readonly<Ref<ConvexUser | null>> | Authenticated user data |
isAuthenticated | ComputedRef<boolean> | True when user is logged in |
isPending | Readonly<Ref<boolean>> | True during auth operations |
authError | Readonly<Ref<string | null>> | Error message if auth failed (e.g., 401/403) |
signOut | () => Promise<void> | Signs out from both Better Auth and Convex |
refreshAuth | () => Promise<void> | Force refresh Convex auth state (fetches fresh token) |
| Property | Type | Description |
|---|---|---|
client | AuthClient | null | Raw Better Auth client instance (null during SSR) |
signIn | AuthClient['signIn'] | Better Auth sign-in methods (client-only) |
signUp | AuthClient['signUp'] | Better Auth sign-up methods (client-only) |
interface ConvexUser {
id: string
name: string
email: string
emailVerified?: boolean
image?: string
createdAt?: string
updatedAt?: string
}
| Layer | Example | Used by | How to add fields |
|---|---|---|---|
| Better Auth core schema | user.additionalFields.role | authClient.useSession(), plugin endpoints, Better Auth DB records | Better Auth additionalFields + inferAdditionalFields(...) |
| Convex JWT claims | useConvexAuth().user.role | Nuxt UI/auth state convenience fields | convex({ jwt.definePayload }) + ConvexUser augmentation |
| Your app's Convex tables | users.role, users.organizationId | Authoritative permissions/business data | Convex schema + triggers/queries/mutations |
additionalFields.useConvexAuth().user: add JWT claims and extend ConvexUser.user.additionalFields / session.additionalFields, use Better Auth's core schema config and client type inference.See also: Better Auth: Extending Core SchemauseConvexAuth().client to be strongly typed automatically.admin, organization, etc.) and typed additionalFields, create a custom client with plugin client adapters.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 },
},
},
})
}
// Type-only bridge for frontend inference (safe to import with `import type`)
export type AppAuth = ReturnType<typeof createAuth>
import { convexClient } from '@convex-dev/better-auth/client/plugins'
import { createAuthClient } from 'better-auth/vue'
import { inferAdditionalFields, adminClient } 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 session = authClient.useSession()
// Typed from Better Auth additionalFields (not from Convex JWT claims)
const orgId = computed(() => session.value.data?.user.organizationId)
const marketingOptIn = computed(() => session.value.data?.user.marketingOptIn)
</script>
import type { AppAuth } only. Do not runtime-import your server auth instance into client code.useConvexAuth().user is typed as ConvexUser. You can extend it with TypeScript module augmentation so app-specific fields like role, authId, or organizationId are recognized by your editor and type checker.declare module 'better-convex-nuxt/dist/runtime/utils/types' {
interface ConvexUser {
role?: 'owner' | 'admin' | 'member' | 'viewer'
authId?: string
organizationId?: string
}
}
export {}
user.role (or any custom field) to exist at runtime, that field must be included in the Convex JWT payload.import { convex } from '@convex-dev/better-auth/plugins'
plugins: [
convex({
authConfig,
jwt: {
definePayload: ({ user }) => ({
// Keep standard fields used by useConvexAuth()
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
image: user.image ?? undefined,
// Custom claims
authId: user.id,
role: 'member', // example only
}),
},
}),
]
<script setup lang="ts">
const { user, refreshAuth } = useConvexAuth()
const role = computed(() => user.value?.role)
async function refreshClaims() {
await refreshAuth()
}
</script>
<template>
<p>JWT role claim: {{ role || '(no claim)' }}</p>
<button @click="refreshClaims">Refresh Auth Claims</button>
</template>
useConvexAuth().user as identity + convenience claims (good for UI display and lightweight client checks).usePermissions() / api.auth.getPermissionContext) as the authoritative source for roles and org membership.jwt.definePayload() to build claims. Prefer data already available on the auth user/session, or fetch authoritative role/org data separately via Convex queries.useConvexAuth().refreshAuth() after the change to fetch a fresh token.useConvexAuth() internallynuxt.config.ts:export default defineNuxtConfig({
convex: {
url: process.env.CONVEX_URL,
skipAuthRoutes: [
'/', // Home page
'/pricing', // Pricing page
'/about', // About page
'/docs/**', // All docs pages
'/blog/**', // All blog pages
],
},
})
/about matches only /about/blog/* matches /blog/post but not /blog/post/comments/docs/** matches /docs, /docs/guide, /docs/guide/authdefinePageMeta:<script setup lang="ts">
definePageMeta({
skipConvexAuth: true,
})
</script>
<template>
<div>
<h1>Welcome to Our App</h1>
<!-- Auth checks skipped for this page -->
</div>
</template>
| Skip auth | Don't skip auth |
|---|---|
| Marketing/landing pages | Dashboard |
| Public blog posts | User settings |
| Documentation | Protected content |
| Pricing page | Admin panels |