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'
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
<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 unlesscanLoadMoreistrue, so only one older page loads at a time.statusdescribes the first page. Loading older entries setsisLoadingMoreand keepsstatusatsuccess.- A failed older page keeps the loaded messages and sets
error. The nextloadMore()retries it. isExhaustedbecomestrueafter 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.