This module uses a hybrid SSR + WebSocket approach that gives you the best of both worlds:
Browser Request
|
v
+------------------+
| Nuxt Server | 1. SSR: Fetch data via HTTP
| (HTTP) | - Fast initial render
+------------------+ - SEO-friendly HTML
|
v
+------------------+
| HTML Response | 2. Hydration: Client receives
| with Data | pre-fetched data (no flash!)
+------------------+
|
v
+------------------+
| Convex Client | 3. Real-time: WebSocket starts
| (WebSocket) | - Live updates from any client
+------------------+ - Automatic sync
| Traditional SPA | This Module |
|---|---|
| Blank page → API call → Content | Content immediately in HTML |
| Loading spinner on navigation | Instant page transitions |
| Manual refetching after mutations | Automatic real-time sync |
| Complex state management | Reactive data binding |
When a user requests a page, Nuxt runs your queries on the server via HTTP:
// This runs on the server during SSR
const { data: posts } = await useConvexQuery(api.posts.list, {})
The server:
The browser receives pre-rendered HTML with data already embedded. Vue hydrates the page without any loading flash because the data is already there.
After hydration, the Convex client opens a WebSocket connection and subscribes to your queries. From this point on:
Choose behavior with await useConvexQuery(...) plus server:
| Pattern | SSR Behavior | Client Navigation | Use Case |
|---|---|---|---|
await useConvexQuery(...) | Fetches data and renders with payload | Blocks until data resolves | Default. Critical page content and SEO paths |
await useConvexQuery(..., { server: false }) | Skips SSR fetch | Blocks on client fetch | Client-only/session-specific data |
await useConvexQuery(...) (Default)
const { data } = await useConvexQuery(api.posts.get, { id })
await useConvexQuery(..., { server: false })
const { data } = await useConvexQuery(api.session.get, {}, { server: false })
useConvexPaginatedQuery with Load More or infinite scroll.useConvexQuery.server: true (default).server: false.await useConvexQuery.Auth state is pre-populated during SSR for zero loading flash:
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
- User sees authenticated UI immediately
|
v
4. Client Hydration
- Receives auth state (no flash!)
- Initializes ConvexClient with token
- Starts WebSocket subscription
Users see authenticated content immediately without a flash of unauthenticated state.
Understanding the full lifecycle helps debug issues:
await useConvexQuery(api.posts.list, {})
|
v
[SSR Phase]
- Runs on Nuxt server
- HTTP request to Convex
- Data embedded in HTML
|
v
[Hydration]
- Client receives data
- Vue hydrates (no fetch!)
- status: 'success'
|
v
[Subscription]
- WebSocket connection opens
- Query subscribes for updates
- Real-time sync begins
|
v
[Live Updates]
- Any client mutates data
- Convex pushes update
- Your UI updates automatically
In your Convex dashboard, you may see queries called twice:
Q posts:list (HTTP API) <- SSR
Q posts:list (WebSocket) <- Client subscription
This is expected. The HTTP call is for SSR (one-time), and the WebSocket is for real-time updates. Convex caches efficiently, so the WebSocket call returns instantly with cached data.
To avoid this duplication:
subscribe: false for data that doesn't need real-time updatesserver: false for client-only dataFor server-backed entities (posts, tasks, comments, memberships), Convex query state already acts as shared app state:
You can still use Pinia for UI-only state (modals, local wizard steps, feature flags), but you typically do not need Pinia to mirror Convex database entities.