<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:
1. Browser Request
Cookie: session_token=xxx
|
v
2. SSR Plugin (server)
- Read session cookie
- Exchange for JWT via Better Auth
- Fetch user data
- Store in state for hydration
|
v
3. HTML Response
- Contains pre-populated auth state
|
v
4. Client Hydration
- Receives auth state (no flash!)
- Initializes ConvexClient with token
- Starts WebSocket subscription
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
For localhost development, keep auth requests same-origin through the Nuxt proxy (/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.NUXT_PUBLIC_CONVEX_SITE_URL || process.env.CONVEX_SITE_URL,
},
});
CONVEX_URL=https://your-dev-deployment.convex.cloud
NUXT_PUBLIC_CONVEX_SITE_URL=https://your-dev-deployment.convex.site
SITE_URL=http://localhost:3000
This avoids browser CORS preflight failures caused by cross-origin auth requests to production domains.
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.Access the Better Auth client for sign-in and sign-up operations.
Use useConvexAuth().signOut() for logout so Convex auth state clears reactively.
<script setup lang="ts">
const authClient = useAuthClient();
async function signInWithEmail(email: string, password: string) {
const { data, error } = await authClient.signIn.email({
email,
password,
});
if (error) {
console.error("Sign in failed:", error.message);
} else {
navigateTo("/dashboard");
}
}
const { signOut } = useConvexAuth();
async function handleSignOut() {
await signOut();
navigateTo("/");
}
</script>
null during SSR. Auth operations are client-only.useAuthClient() 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>
<script setup lang="ts">
const authClient = useAuthClient();
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 authClient.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 authClient = useAuthClient();
async function signInWithGoogle() {
await authClient.signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
}
async function signInWithGitHub() {
await authClient.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 authClient = useAuthClient();
const name = ref("");
const email = ref("");
const password = ref("");
const error = ref<string | null>(null);
async function handleSignUp() {
const { error: authError } = await authClient.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>
Declarative components for rendering content based on authentication state.
| Component | Shows content when... |
|---|---|
<ConvexAuthLoading> | Auth state is being determined |
<ConvexAuthenticated> | User is authenticated |
<ConvexUnauthenticated> | User is NOT authenticated |
<template>
<ConvexAuthLoading>
<div class="loading">Checking authentication...</div>
</ConvexAuthLoading>
<ConvexAuthenticated>
<Dashboard />
</ConvexAuthenticated>
<ConvexUnauthenticated>
<LoginPrompt />
</ConvexUnauthenticated>
</template>
Renders content while auth state is being determined.
<template>
<ConvexAuthLoading>
<div class="auth-loading">
<Spinner />
<p>Loading...</p>
</div>
</ConvexAuthLoading>
</template>
Renders content when user is authenticated.
<template>
<ConvexAuthenticated>
<nav class="user-nav">
<NuxtLink to="/dashboard">Dashboard</NuxtLink>
<NuxtLink to="/settings">Settings</NuxtLink>
<UserMenu />
</nav>
</ConvexAuthenticated>
</template>
Renders content when user is NOT authenticated.
<template>
<ConvexUnauthenticated>
<div class="guest-nav">
<NuxtLink to="/auth/signin">Sign In</NuxtLink>
<NuxtLink to="/auth/signup">Sign Up</NuxtLink>
</div>
</ConvexUnauthenticated>
</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>
Use 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')
}
})
If you need to query Convex in route middleware (for role/permission checks), use useConvexQuery(..., { subscribe: false }).
import { api } from '~~/convex/_generated/api'
export default defineNuxtRouteMiddleware(async () => {
const { data: context } = await useConvexQuery(
api.auth.getPermissionContext,
{},
{
server: true,
subscribe: false,
},
)
if (!context.value || context.value.role !== 'admin') {
return navigateTo('/')
}
})
fetchQuery() only in server-only contexts (API routes, Nitro server/middleware, webhooks). Nuxt route middleware also runs during client-side navigation, so fetchQuery() alone will not cover SPA navigation.<script setup lang="ts">
const { isAuthenticated } = useConvexAuth();
// Only fetch when authenticated
const { data: profile } = 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>
For proper SSR handling, wrap auth components in <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>
Auth components are syntax sugar over 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>
When to use components:
When to use composable:
user or token<script setup lang="ts">
const authClient = useAuthClient();
const { isAuthenticated } = 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 authClient.signIn.email({
email: email.value,
password: password.value,
});
if (authError) {
error.value = authError.message;
}
} finally {
loading.value = false;
}
}
async function handleGoogleSignIn() {
await authClient.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 |
interface ConvexUser {
id: string;
name: string;
email: string;
emailVerified?: boolean;
image?: string;
createdAt?: string;
updatedAt?: string;
}
There are three different places where "extra user fields" can exist in a Better Auth + Convex app:
| 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 |
Use the right layer for the job:
additionalFields.useConvexAuth().user: add JWT claims and extend ConvexUser.For Better Auth fields like user.additionalFields / session.additionalFields, use Better Auth's core schema config and client type inference.
See also: Better Auth: Extending Core Schema
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 {};
Type augmentation only changes TypeScript. For 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.| Return | Type | Description |
|---|---|---|
authClient | AuthClient | null | Better Auth client instance |
useAuthClient() returns the module-managed base client. For plugin-specific typed methods (for example adminClient() APIs or inferAdditionalFields(...) typing), create a custom client composable as shown above.
useConvexAuth() internallyFor marketing pages that never need authentication, you can skip auth checks entirely to avoid unnecessary requests.
Skip auth for entire route patterns in nuxt.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
],
},
});
Supported patterns:
/about matches only /about/blog/* matches /blog/post but not /blog/post/comments/docs/** matches /docs, /docs/guide, /docs/guide/authSkip auth for individual pages using definePageMeta:
<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 |