Server Side

SSR & Hydration

Server-side rendering, hydration, and skeleton loader patterns for Convex queries.

How SSR Works

  1. Server: Query executes via HTTP, result embedded in HTML
  2. Hydration: Client receives pre-fetched data (no loading flash)
  3. Subscription: WebSocket starts for real-time updates
  4. Updates: Changes sync automatically

CSR-Only Mode

The module fully supports ssr: false for client-side only rendering:

nuxt.config.ts
export default defineNuxtConfig({
  ssr: false, // CSR-only mode
  convex: {
    url: process.env.CONVEX_URL,
  }
})

How CSR Mode Works

  1. No server rendering - HTML shell is served
  2. Client initialization - Vue app bootstraps in browser
  3. Auth check - Token fetched from /api/auth/convex/token
  4. User extraction - User data decoded from JWT
  5. Real-time - WebSocket connection established

Trade-offs

SSR ModeCSR Mode
Auth pre-populated (0 requests)Auth fetched client-side (1 request)
Faster first contentful paintSimpler deployment
SEO-friendlySmaller bundle
Better for content sitesBetter for apps behind login

Optimizing CSR Mode

Skip auth checks on marketing pages to avoid unnecessary requests:

nuxt.config.ts
export default defineNuxtConfig({
  ssr: false,
  convex: {
    url: process.env.CONVEX_URL,
    skipAuthRoutes: ['/', '/pricing', '/docs/**']
  }
})

See Skipping Auth Checks for details.


Pattern: ClientOnly with Skeleton

Prerender pages with skeleton placeholders that hydrate to real content.

When to use

  • Page sections requiring authentication
  • Content that can't be fetched during SSR
  • Prerendering with dynamic content
  • Avoiding hydration mismatches

Example

<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>

Anti-pattern: Missing fallback

<!-- WRONG: Blank space during SSR -->
<ClientOnly>
  <PostsList :posts="posts" />
</ClientOnly>

<!-- RIGHT: Skeleton in fallback -->
<ClientOnly>
  <PostsList :posts="posts" />
  <template #fallback>
    <SkeletonPostsList />
  </template>
</ClientOnly>

Anti-pattern: Mismatched structure

<!-- 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>

Pattern: Status-Based Skeleton

Show skeleton based on query status for client-side navigation.

When to use

  • Client-side navigation
  • Refetching after mutation
  • Reactive args changes

Example

<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>

Pattern: Full 4-State 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>

Pattern: Inline Skeleton

Skeleton for single values within a layout.

When to use

  • Single value loading independently
  • Mix of static and dynamic content
  • Preserving layout during load

Example

<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>

Pattern: Multiple Queries

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>

Pattern: Auth-Protected SSR

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>

Skeleton Component Examples

Card Skeleton

<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>

List Item Skeleton

<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>

Common Mistakes

1. Not matching fallback structure

<!-- 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>

2. Checking data instead of status

<!-- 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>

3. Forgetting ClientOnly for auth content

<!-- 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>