<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
Access the Better Auth client for sign-in, sign-up, and sign-out operations.
<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')
}
}
async function signOut() {
await authClient.signOut()
navigateTo('/')
}
</script>
null during SSR. Auth operations are client-only.<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 authClient = useAuthClient()
const { isAuthenticated, user } = useConvexAuth()
async function handleSignOut() {
await authClient.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>
<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
}
| Return | Type | Description |
|---|---|---|
authClient | AuthClient | null | Better Auth client instance |
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 |