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 = await useConvexPaginatedQuery(
  api.messages.list,
  { channelId },
  { initialNumItems: 20, auth: 'required' },
)

const send = useConvexMutation(api.messages.send, {
  optimisticUpdate: (store, args) => {
    insertAtTop({
      query: api.messages.list,
      argsToMatch: { channelId: args.channelId },
      store,
      item: {
        _id: crypto.randomUUID() as Id<'messages'>,
        _creationTime: Date.now(),
        channelId: args.channelId,
        authorId: currentUserId.value,
        body: args.body,
      },
    })
  },
})

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 hasNextPage and status.

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.