Data Fetching

Pagination

Paginated queries with Load More and infinite scroll functionality.

Basic Usage

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

API Reference

Parameters

ParameterTypeDescription
queryPaginatedQueryReferenceConvex paginated query function
argsMaybeRef<Args | 'skip'>Query arguments or 'skip'
optionsUseConvexPaginatedQueryOptionsConfiguration

Options

OptionTypeDefaultDescription
initialNumItemsnumber10Items in first page
serverbooleanfalseRun first page on server (SSR)
lazybooleanfalseDon't block navigation when awaited
subscribebooleantrueEnable real-time WebSocket updates
publicbooleanfalseSkip auth token checks (for public queries)
default() => Item[]-Factory for placeholder data while loading
transform(items: Item[]) => T[]-Transform concatenated results
Enable module-level logging via the logging config option for debugging paginated queries.

Returns

PropertyTypeDescription
resultsComputedRef<Item[]>All loaded items concatenated
statusComputedRef<PaginationStatus>Current pagination state
isLoadingComputedRef<boolean>Loading first or more pages
loadMore(numItems: number) => voidLoad next page
errorRef<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() => voidClear all data and subscriptions

Pagination Status

StatusMeaning
'LoadingFirstPage'Initial page is loading
'CanLoadMore'More items available
'LoadingMore'Loading additional page
'Exhausted'All items loaded

Convex Backend

Your Convex query must use pagination:

convex/messages.ts
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)
  },
})

Patterns

Lazy Loading with SSR (Best of Both Worlds)

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>

Load More Button

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

Infinite Scroll

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

With Filters

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

With SSR Skeleton

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

Transform Results

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>

Manual Refresh (Static Data)

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>

With Default Placeholder

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>

Optimistic Updates

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,
    })
  },
})
See Optimistic Updates for all paginated query helpers.

TypeScript

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
)

Common Mistakes

1. Calling loadMore without checking status

// WRONG: May cause issues
function handleScroll() {
  loadMore(10)
}

// RIGHT: Check status first
function handleScroll() {
  if (status.value === 'CanLoadMore') {
    loadMore(10)
  }
}

2. Using regular query for paginated data

// WRONG: Use useConvexPaginatedQuery
const { data } = useConvexQuery(api.messages.list, { paginationOpts: ... })

// RIGHT: Use the paginated composable
const { results } = useConvexPaginatedQuery(api.messages.list, {})

3. Forgetting paginationOpts in Convex query

// 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)
  },
})