<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 === 'CanLoadMore'"
@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 | MaybeRef<Args | 'skip'> | Query arguments or 'skip' |
options | UseConvexPaginatedQueryOptions | Configuration |
| Option | Type | Default | Description |
|---|---|---|---|
initialNumItems | number | 10 | Items in first page |
server | boolean | false | Run first page on server (SSR) |
lazy | boolean | false | Don't block navigation when awaited |
subscribe | boolean | true | Enable real-time WebSocket updates |
public | boolean | false | Skip auth token checks (for public queries) |
default | () => Item[] | - | Factory for placeholder data while loading |
transform | (items: Item[]) => T[] | - | Transform concatenated results |
| Property | Type | Description |
|---|---|---|
results | ComputedRef<Item[]> | All loaded items concatenated |
status | ComputedRef<PaginationStatus> | Current pagination state |
isLoading | ComputedRef<boolean> | Loading first or more pages |
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 |
clear | () => void | Clear all data and subscriptions |
| Status | Meaning |
|---|---|
'LoadingFirstPage' | Initial page is loading |
'CanLoadMore' | More items available |
'LoadingMore' | Loading additional page |
'Exhausted' | All items loaded |
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 server: true, lazy: true for SSR-rendered data with instant client navigation:
<script setup lang="ts">
// SSR: Fetches data (good for SEO)
// Client nav: Instant, shows LoadingFirstPage state
const { results, status, loadMore } = await useConvexPaginatedQuery(
api.posts.list,
{},
{ initialNumItems: 10, server: true, lazy: true }
)
</script>
<template>
<div>
<!-- Show skeleton while loading on client nav -->
<div v-if="status === 'LoadingFirstPage'" 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 === 'CanLoadMore'" @click="loadMore(10)">
Load More
</button>
</div>
</div>
</template>
<script setup lang="ts">
const { results, status, loadMore } = 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 === 'CanLoadMore'"
@click="loadMore(10)"
>
Load More
</button>
<div v-else-if="status === 'LoadingMore'" 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 } = 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 === 'CanLoadMore' && !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 } = 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 === 'LoadingFirstPage'">Loading...</div>
<div v-else>
<PostCard v-for="post in results" :key="post._id" :post="post" />
<button v-if="status === 'CanLoadMore'" @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 === 'LoadingFirstPage'" 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 === 'CanLoadMore'" @click="loadMore(10)">
Load More
</button>
<div v-else-if="status === 'LoadingMore'">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, // No WebSocket subscriptions
}
)
// Manually refresh when needed
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
lazy: true,
}
)
</script>
Special helpers are available for optimistic updates with paginated queries:
import { insertAtTop, deleteFromPaginatedQuery } from '#imports'
const { mutate: addMessage } = useConvexMutation(api.messages.send, {
optimisticUpdate: (localStore, args) => {
insertAtTop({
paginatedQuery: api.messages.list,
localQueryStore: localStore,
item: {
_id: crypto.randomUUID() as Id<'messages'>,
_creationTime: Date.now(),
body: args.body,
authorId: currentUser._id,
},
})
},
})
const { mutate: deleteMessage } = useConvexMutation(api.messages.remove, {
optimisticUpdate: (localStore, args) => {
deleteFromPaginatedQuery({
paginatedQuery: api.messages.list,
localQueryStore: localStore,
shouldDelete: (msg) => msg._id === args.messageId,
})
},
})
Full type inference:
// Item type is inferred from query return
const { results } = useConvexPaginatedQuery(api.messages.list, {})
// results is ComputedRef<Message[]>
// Args are typed
const { results } = 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 === 'CanLoadMore') {
loadMore(10)
}
}
// WRONG: Use useConvexPaginatedQuery
const { data } = useConvexQuery(api.messages.list, { paginationOpts: ... })
// RIGHT: Use the paginated composable
const { results } = 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)
},
})