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