Skip to main content

Real-time feed

Build a paginated live feed with stable ordering and optimistic inserts.

Result

The first feed page renders during SSR. Users can load older entries and see new entries immediately.

Backend

convex/messages.ts
import { paginationOptsValidator } from 'convex/server'
import { v } from 'convex/values'
import { mutation, query } from './_generated/server'
import { auth } from './auth'

export const list = query({
  args: {
    channelId: v.id('channels'),
    paginationOpts: paginationOptsValidator,
  },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('messages')
      .withIndex('by_channel_created', (q) => q.eq('channelId', args.channelId))
      .order('desc')
      .paginate(args.paginationOpts)
  },
})

export const send = mutation({
  args: { channelId: v.id('channels'), body: v.string() },
  handler: async (ctx, args) => {
    const user = await auth.requireUser(ctx)
    return await ctx.db.insert('messages', {
      channelId: args.channelId,
      authorId: user.id,
      body: args.body.trim(),
    })
  },
})

Component

app/components/ChannelFeed.vue
<script setup lang="ts">
import type { Id } from '~~/convex/_generated/dataModel'
import { api } from '#convex/api'

const props = defineProps<{ channelId: Id<'channels'> }>()
const { user } = useConvexAuth()

const {
  data: messages,
  status,
  error,
  canLoadMore,
  isLoadingMore,
  isExhausted,
  loadMore,
} = useConvexPaginatedQuery(api.messages.list, () => ({ channelId: props.channelId }), {
  initialNumItems: 20,
  auth: 'required',
})

const { mutate: send } = useConvexMutation(api.messages.send, {
  optimisticUpdate: (store, args) => {
    const item = {
      _id: crypto.randomUUID() as Id<'messages'>,
      _creationTime: Date.now(),
      channelId: args.channelId,
      authorId: user.value?.id ?? '',
      body: args.body,
    }

    for (const loaded of store.getAllQueries(api.messages.list)) {
      if (
        loaded.value !== undefined &&
        loaded.args.channelId === args.channelId &&
        loaded.args.paginationOpts.cursor === null
      ) {
        store.setQuery(api.messages.list, loaded.args, {
          ...loaded.value,
          page: [item, ...loaded.value.page],
        })
      }
    }
    return undefined
  },
})
</script>

<template>
  <p v-if="status === 'pending'">Loading messages…</p>
  <p v-else-if="status === 'error'">Could not load messages.</p>

  <template v-else>
    <MessageList :messages="messages ?? []" />

    <button v-if="canLoadMore || isLoadingMore" :disabled="!canLoadMore" @click="loadMore(20)">
      {{ isLoadingMore ? 'Loading older messages…' : 'Load older messages' }}
    </button>
    <p v-else-if="isExhausted">This is the start of the channel.</p>

    <p v-if="error" role="alert">Older messages could not be loaded.</p>
  </template>
</template>

Convex replaces the temporary message with the saved one when the mutation succeeds. Do not store the temporary ID anywhere else.

Important behavior

  • Live subscriptions cover loaded pages.
  • Unloaded history remains unloaded.
  • Stable backend ordering prevents items from jumping unpredictably.
  • loadMore() does nothing unless canLoadMore is true, so only one older page loads at a time.
  • status describes the first page. Loading older entries sets isLoadingMore and keeps status at success.
  • A failed older page keeps the loaded messages and sets error. The next loadMore() retries it.
  • isExhausted becomes true after the oldest message is loaded.

Verify

  • Initial messages appear in SSR HTML.
  • A second browser sees new messages live.
  • A failed send rolls back the optimistic item.
  • Loading more never starts two page requests concurrently.
  • Anonymous callers cannot list or send private channel messages.