The module fully supports ssr: false for client-side only rendering:
export default defineNuxtConfig({
ssr: false, // CSR-only mode
convex: {
url: process.env.CONVEX_URL,
}
})
/api/auth/convex/token| SSR Mode | CSR Mode |
|---|---|
| Auth pre-populated (0 requests) | Auth fetched client-side (1 request) |
| Faster first contentful paint | Simpler deployment |
| SEO-friendly | Smaller bundle |
| Better for content sites | Better for apps behind login |
Skip auth checks on marketing pages to avoid unnecessary requests:
export default defineNuxtConfig({
ssr: false,
convex: {
url: process.env.CONVEX_URL,
skipAuthRoutes: ['/', '/pricing', '/docs/**']
}
})
See Skipping Auth Checks for details.
Prerender pages with skeleton placeholders that hydrate to real content.
<script setup lang="ts">
import { api } from '~/convex/_generated/api'
const { data: posts, status, error } = useConvexQuery(api.posts.list, {})
</script>
<template>
<ClientOnly>
<!-- Loading -->
<div v-if="status === 'pending'" class="posts-list">
<SkeletonPostCard v-for="i in 3" :key="i" />
</div>
<!-- Error -->
<div v-else-if="status === 'error'" class="error">
Failed to load: {{ error?.message }}
</div>
<!-- Empty -->
<div v-else-if="status === 'success' && !posts?.length">
No posts yet.
</div>
<!-- Data -->
<div v-else-if="status === 'success'" class="posts-list">
<PostCard v-for="post in posts" :key="post._id" :post="post" />
</div>
<!-- SSR fallback -->
<template #fallback>
<div class="posts-list">
<SkeletonPostCard v-for="i in 3" :key="i" />
</div>
</template>
</ClientOnly>
</template>
<!-- WRONG: Blank space during SSR -->
<ClientOnly>
<PostsList :posts="posts" />
</ClientOnly>
<!-- RIGHT: Skeleton in fallback -->
<ClientOnly>
<PostsList :posts="posts" />
<template #fallback>
<SkeletonPostsList />
</template>
</ClientOnly>
<!-- WRONG: Layout shift when content loads -->
<ClientOnly>
<div class="posts-grid">
<PostCard v-for="post in posts" ... />
</div>
<template #fallback>
<p>Loading...</p> <!-- Different structure! -->
</template>
</ClientOnly>
<!-- RIGHT: Same structure -->
<ClientOnly>
<div class="posts-grid">
<PostCard v-for="post in posts" ... />
</div>
<template #fallback>
<div class="posts-grid">
<SkeletonPostCard v-for="i in 3" :key="i" />
</div>
</template>
</ClientOnly>
Show skeleton based on query status for client-side navigation.
<script setup lang="ts">
const category = ref<string | null>(null)
const { data: posts, status, error } = useConvexQuery(
api.posts.byCategory,
computed(() => category.value ? { category: category.value } : 'skip')
)
</script>
<template>
<select v-model="category">
<option :value="null">Select category...</option>
<option value="tech">Tech</option>
<option value="design">Design</option>
</select>
<!-- Idle: query skipped -->
<div v-if="status === 'idle'" class="hint">
Select a category to see posts
</div>
<!-- Pending: loading -->
<div v-else-if="status === 'pending'" class="posts-list">
<SkeletonPostCard v-for="i in 3" :key="i" />
</div>
<!-- Error -->
<div v-else-if="status === 'error'" class="error">
{{ error?.message }}
</div>
<!-- Empty -->
<div v-else-if="status === 'success' && !posts?.length" class="empty">
No posts in this category
</div>
<!-- Data -->
<div v-else-if="status === 'success'" class="posts-list">
<PostCard v-for="post in posts" :key="post._id" :post="post" />
</div>
</template>
Complete pattern combining all states.
<template>
<ClientOnly>
<!-- idle: query skipped -->
<div v-if="status === 'idle'" class="idle-state">
[Prompt to enable query]
</div>
<!-- pending: loading -->
<div v-else-if="status === 'pending'" class="loading-state">
<SkeletonComponent />
</div>
<!-- error: failed -->
<div v-else-if="status === 'error'" class="error-state">
<p>Failed to load: {{ error?.message }}</p>
<button @click="refresh">Retry</button>
</div>
<!-- success but empty -->
<div v-else-if="status === 'success' && !data?.length" class="empty-state">
<p>No items found</p>
<button @click="handleCreate">Create first item</button>
</div>
<!-- success with data -->
<div v-else-if="status === 'success'" class="content">
<Item v-for="item in data" :key="item._id" :item="item" />
</div>
<!-- SSR fallback -->
<template #fallback>
<div class="loading-state">
<SkeletonComponent />
</div>
</template>
</ClientOnly>
</template>
Skeleton for single values within a layout.
<script setup lang="ts">
const { data: org, status } = useConvexQuery(
api.organizations.getCurrent,
computed(() => orgId.value ? {} : 'skip')
)
</script>
<template>
<div class="info-row">
<span class="label">Organization:</span>
<ClientOnly>
<span v-if="status === 'pending'" class="skeleton" style="width: 120px" />
<span v-else-if="status === 'success'">{{ org?.name }}</span>
<span v-else-if="status === 'error'" class="error">Failed</span>
<template #fallback>
<span class="skeleton" style="width: 120px" />
</template>
</ClientOnly>
</div>
</template>
Handle multiple queries with parallel loading.
<script setup lang="ts">
// Both queries run in parallel during SSR
const [postsResult, categoriesResult] = await Promise.all([
useConvexQuery(api.posts.list, {}),
useConvexQuery(api.categories.list, {})
])
const { data: posts, status: postsStatus } = postsResult
const { data: categories } = categoriesResult
</script>
<template>
<ClientOnly>
<div v-if="postsStatus === 'pending'">
<SkeletonPostCard v-for="i in 3" :key="i" />
</div>
<div v-else-if="postsStatus === 'success'">
<PostCard
v-for="post in posts"
:key="post._id"
:post="post"
:categories="categories"
/>
</div>
<template #fallback>
<SkeletonPostCard v-for="i in 3" :key="i" />
</template>
</ClientOnly>
</template>
Handle authenticated content with proper SSR.
<script setup lang="ts">
const { isAuthenticated, isPending } = useConvexAuth()
const { data: dashboard, status } = useConvexQuery(
api.dashboard.get,
computed(() => isAuthenticated.value ? {} : 'skip')
)
</script>
<template>
<div class="page">
<!-- Auth loading -->
<div v-if="isPending" class="loading">
Checking authentication...
</div>
<!-- Not authenticated -->
<div v-else-if="!isAuthenticated" class="auth-required">
<p>Please sign in to continue</p>
<NuxtLink to="/auth/signin">Sign In</NuxtLink>
</div>
<!-- Authenticated content -->
<div v-else>
<ClientOnly>
<div v-if="status === 'pending'">
<SkeletonDashboard />
</div>
<div v-else-if="status === 'success'">
<Dashboard :data="dashboard" />
</div>
<template #fallback>
<SkeletonDashboard />
</template>
</ClientOnly>
</div>
</div>
</template>
<template>
<div class="skeleton-card">
<div class="skeleton skeleton-image" />
<div class="skeleton-content">
<div class="skeleton skeleton-title" />
<div class="skeleton skeleton-text" />
<div class="skeleton skeleton-text short" />
</div>
</div>
</template>
<style scoped>
.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
.skeleton-image {
height: 200px;
}
.skeleton-title {
height: 24px;
width: 70%;
margin-bottom: 8px;
}
.skeleton-text {
height: 16px;
width: 100%;
margin-bottom: 4px;
}
.skeleton-text.short {
width: 60%;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
<template>
<div class="skeleton-list-item">
<div class="skeleton skeleton-avatar" />
<div class="skeleton-info">
<div class="skeleton skeleton-name" />
<div class="skeleton skeleton-meta" />
</div>
</div>
</template>
<style scoped>
.skeleton-list-item {
display: flex;
gap: 12px;
padding: 12px;
}
.skeleton-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
.skeleton-info {
flex: 1;
}
.skeleton-name {
height: 16px;
width: 120px;
margin-bottom: 4px;
}
.skeleton-meta {
height: 12px;
width: 80px;
}
</style>
<!-- WRONG: Different layout causes shift -->
<ClientOnly>
<div class="grid-3">
<Card v-for="item in items" :key="item._id" />
</div>
<template #fallback>
<p>Loading...</p>
</template>
</ClientOnly>
<!-- RIGHT: Same structure -->
<ClientOnly>
<div class="grid-3">
<Card v-for="item in items" :key="item._id" />
</div>
<template #fallback>
<div class="grid-3">
<SkeletonCard v-for="i in 3" :key="i" />
</div>
</template>
</ClientOnly>
<!-- WRONG: Can't distinguish loading from empty -->
<template>
<div v-if="!posts">Loading...</div>
<div v-else>{{ posts.length }} posts</div>
</template>
<!-- RIGHT: Use status -->
<template>
<div v-if="status === 'pending'">Loading...</div>
<div v-else-if="status === 'success' && !posts?.length">No posts</div>
<div v-else-if="status === 'success'">{{ posts.length }} posts</div>
</template>
<!-- WRONG: Hydration mismatch -->
<template>
<div v-if="isAuthenticated">
Welcome, {{ user.name }}!
</div>
</template>
<!-- RIGHT: Wrap in ClientOnly -->
<template>
<ClientOnly>
<div v-if="isAuthenticated">
Welcome, {{ user.name }}!
</div>
<template #fallback>
<div class="skeleton" style="width: 150px" />
</template>
</ClientOnly>
</template>