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'

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 identity = await ctx.auth.getUserIdentity()
    if (!identity) throw new Error('Unauthenticated')
    return await ctx.db.insert('messages', {
      channelId: args.channelId,
      authorId: identity.subject,
      body: args.body.trim(),
    })
  },
})

Page state

ts
const feed = useConvexPaginatedQuery(
  api.messages.list,
  { channelId },
  { initialNumItems: 20, auth: 'required' },
)

const send = useConvexMutation(api.messages.send, {
  optimisticUpdate: (store, args) => {
    const item = {
      _id: crypto.randomUUID() as Id<'messages'>,
      _creationTime: Date.now(),
      channelId: args.channelId,
      authorId: currentUserId.value,
      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
  },
})

Use the server result to reconcile the temporary item. Do not persist the temporary ID outside local rendering.

Important behavior

  • Live subscriptions cover loaded pages.
  • Unloaded history remains unloaded.
  • Stable backend ordering prevents items from jumping unpredictably.
  • loadMore() is guarded by canLoadMore and isLoading.

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 cursor requests concurrently.
  • Anonymous callers cannot list or send private channel messages.