<script setup lang="ts">
import { api } from '~~/convex/_generated/api'
// Awaitable - blocks navigation until first page loads
const { results, status, loadMore, isLoading } = await useConvexPaginatedQuery(
api.messages.list,
{},
{ initialNumItems: 10 },
)
</script>
<template>
<div>
<div v-for="message in results" :key="message._id">
{{ message.body }}
</div>
<button v-if="status === 'ready'" @click="loadMore(10)" :disabled="isLoading">Load More</button>
<p v-if="status === 'exhausted'">No more messages</p>
</div>
</template>
| Parameter | Type | Description |
|---|---|---|
query | PaginatedQueryReference | Convex paginated query function |
args | MaybeRefOrGetter<Args | null | undefined> | Query arguments (reactive); null/undefined disables |
options | UseConvexPaginatedQueryOptions | Configuration |
Reactive args are supported. You can pass a getter, ref, computed, or a reactive() object.
const filters = reactive({ category: 'all' })
const pageQuery = await useConvexPaginatedQuery(api.posts.byCategory, filters, {
initialNumItems: 10,
})
| Option | Type | Default | Description |
|---|---|---|---|
initialNumItems | number | 10 | Items in first page |
server | boolean | true | Run first page on server (SSR) |
subscribe | boolean | true | Enable real-time WebSocket updates |
auth | 'auto' | 'none' | 'auto' | Auth token mode ('none' skips token attachment) |
default | () => Item[] | - | Factory for placeholder data while loading |
transform | (items: Item[]) => T[] | - | Transform concatenated results |
If transform is provided, default() output is transformed too.
| Property | Type | Description |
|---|---|---|
results | ComputedRef<Item[]> | All loaded items concatenated |
status | ComputedRef<PaginatedQueryStatus> | Current pagination state |
isLoading | ComputedRef<boolean> | Loading first or more pages |
hasNextPage | ComputedRef<boolean> | true only when status === 'ready' |
loadMore | (numItems: number) => void | Load next page |
error | Ref<Error | null> | Error if any page failed |
refresh | () => Promise<void> | Re-fetch all loaded pages via HTTP |
reset | () => Promise<void> | Clear all pages and restart from first |
| Status | Meaning |
|---|---|
'idle' | Query disabled (enabled: false or args are null/undefined) |
'loading-first-page' | Initial page is loading |
'ready' | Data loaded and more items available |
'loading-more' | Loading additional page |
'exhausted' | All items loaded |
'error' | First page or load-more request failed |
Your Convex query must use pagination:
import { v } from 'convex/values'
import { query } from './_generated/server'
import { paginationOptsValidator } from 'convex/server'
export const list = query({
args: {
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, args) => {
return await ctx.db.query('messages').order('desc').paginate(args.paginationOpts)
},
})
Use await useConvexPaginatedQuery with server: true for SSR-rendered first page and deterministic setup timing:
<script setup lang="ts">
// SSR: Fetches data (good for SEO)
// Client nav: setup waits for first page
const { results, status, loadMore } = await useConvexPaginatedQuery(
api.posts.list,
{},
{ initialNumItems: 10, server: true },
)
</script>
<template>
<div>
<!-- Show skeleton while loading on client nav -->
<div v-if="status === 'loading-first-page'" class="skeleton">
<SkeletonCard v-for="i in 10" :key="i" />
</div>
<!-- Show data when ready -->
<div v-else>
<PostCard v-for="post in results" :key="post._id" :post="post" />
<button v-if="status === 'ready'" @click="loadMore(10)">Load More</button>
</div>
</div>
</template>
<script setup lang="ts">
const { results, status, loadMore } = await useConvexPaginatedQuery(
api.posts.list,
{},
{ initialNumItems: 10 },
)
</script>
<template>
<div class="posts">
<PostCard v-for="post in results" :key="post._id" :post="post" />
<div class="load-more">
<button v-if="status === 'ready'" @click="loadMore(10)">Load More</button>
<div v-else-if="status === 'loading-more'" class="spinner">Loading...</div>
<p v-else-if="status === 'exhausted'" class="end">You've reached the end</p>
</div>
</div>
</template>
<script setup lang="ts">
const { results, status, loadMore, isLoading } = await useConvexPaginatedQuery(
api.posts.list,
{},
{ initialNumItems: 20 },
)
const loadMoreRef = ref<HTMLElement>()
// Load more when sentinel comes into view
useIntersectionObserver(loadMoreRef, ([entry]) => {
if (entry?.isIntersecting && status.value === 'ready' && !isLoading.value) {
loadMore(20)
}
})
</script>
<template>
<div class="infinite-scroll">
<PostCard v-for="post in results" :key="post._id" :post="post" />
<!-- Sentinel element -->
<div ref="loadMoreRef" class="sentinel">
<div v-if="isLoading" class="spinner">Loading...</div>
</div>
</div>
</template>
<script setup lang="ts">
const category = ref('all')
const { results, status, loadMore } = await useConvexPaginatedQuery(
api.posts.byCategory,
computed(() => ({ category: category.value })),
{ initialNumItems: 10 },
)
</script>
<template>
<div>
<select v-model="category">
<option value="all">All</option>
<option value="tech">Tech</option>
<option value="design">Design</option>
</select>
<div v-if="status === 'loading-first-page'">Loading...</div>
<div v-else>
<PostCard v-for="post in results" :key="post._id" :post="post" />
<button v-if="status === 'ready'" @click="loadMore(10)">Load More</button>
</div>
</div>
</template>
<script setup lang="ts">
const { results, status, loadMore } = await useConvexPaginatedQuery(
api.messages.list,
{},
{ initialNumItems: 10 },
)
</script>
<template>
<ClientOnly>
<!-- Loading first page -->
<div v-if="status === 'loading-first-page'" class="messages">
<SkeletonMessage v-for="i in 10" :key="i" />
</div>
<!-- Messages loaded -->
<div v-else class="messages">
<Message v-for="msg in results" :key="msg._id" :message="msg" />
<button v-if="status === 'ready'" @click="loadMore(10)">Load More</button>
<div v-else-if="status === 'loading-more'">Loading...</div>
</div>
<!-- SSR fallback -->
<template #fallback>
<div class="messages">
<SkeletonMessage v-for="i in 10" :key="i" />
</div>
</template>
</ClientOnly>
</template>
Add computed fields or filter data:
<script setup lang="ts">
interface TransformedPost {
_id: string
title: string
formattedDate: string
isRecent: boolean
}
const { results } = await useConvexPaginatedQuery(
api.posts.list,
{},
{
initialNumItems: 10,
transform: (posts): TransformedPost[] =>
posts.map((post) => ({
_id: post._id,
title: post.title,
formattedDate: new Date(post.createdAt).toLocaleDateString(),
isRecent: Date.now() - post.createdAt < 86400000, // 24 hours
})),
},
)
</script>
Disable real-time updates for static/archive pages:
<script setup lang="ts">
const { results, refresh, reset } = await useConvexPaginatedQuery(
api.archive.posts,
{},
{
initialNumItems: 20,
subscribe: false, // Strict HTTP-only mode (no WebSocket subscriptions or loadMore WS fetches)
},
)
// Manually refresh when needed (HTTP re-fetch for all loaded pages)
async function handleRefresh() {
await refresh() // Re-fetches all loaded pages
}
// Reset to first page
async function handleReset() {
await reset() // Clears and restarts
}
</script>
Show placeholder content while loading:
<script setup lang="ts">
const placeholders = Array.from({ length: 10 }, (_, i) => ({
_id: `placeholder-${i}`,
title: 'Loading...',
content: '',
}))
const { results, status } = await useConvexPaginatedQuery(
api.posts.list,
{},
{
initialNumItems: 10,
default: () => placeholders,
server: false, // Force client-side loading to see placeholders
},
)
</script>
Special helpers are available for optimistic updates with paginated queries:
import { insertAtTop, deleteFromPaginatedQuery } from '#imports'
const { execute: addMessage } = useConvexMutation(api.messages.send, {
optimisticUpdate: (localStore, args) => {
insertAtTop({
query: api.messages.list,
store: localStore,
item: {
_id: crypto.randomUUID() as Id<'messages'>,
_creationTime: Date.now(),
body: args.body,
authorId: currentUser._id,
},
})
},
})
const { execute: deleteMessage } = useConvexMutation(api.messages.remove, {
optimisticUpdate: (localStore, args) => {
deleteFromPaginatedQuery({
query: api.messages.list,
store: localStore,
shouldDelete: (msg) => msg._id === args.messageId,
})
},
})
Full type inference:
// Item type is inferred from query return
const { results } = await useConvexPaginatedQuery(api.messages.list, {})
// results is ComputedRef<Message[]>
// Args are typed
const { results } = await useConvexPaginatedQuery(
api.messages.byChannel,
{ channelId: 'invalid' }, // Type error if wrong type
)
// WRONG: May cause issues
function handleScroll() {
loadMore(10)
}
// RIGHT: Check status first
function handleScroll() {
if (status.value === 'ready') {
loadMore(10)
}
}
// WRONG: Use useConvexPaginatedQuery
const { data } = await useConvexQuery(api.messages.list, { paginationOpts: ... })
// RIGHT: Use the paginated composable
const { results } = await useConvexPaginatedQuery(api.messages.list, {})
// WRONG: Missing pagination in backend
export const list = query({
handler: async (ctx) => {
return await ctx.db.query('messages').collect() // Not paginated!
},
})
// RIGHT: Use paginate()
export const list = query({
args: { paginationOpts: paginationOptsValidator },
handler: async (ctx, args) => {
return await ctx.db.query('messages').paginate(args.paginationOpts)
},
})