Doc Status: Good | ✓ Clear summary | ✓ Easy to read | ✓ Matches code | ✓ Good structure | ✓ Professional look | ✓ Visual components
Realtime via useQuery
useQuery creates a persistent WebSocket subscription — when the underlying data changes, the component re-renders automatically.
'use client';
import { useQuery } from 'convex/react';
import { api } from '@packages/backend/convex/_generated/api';
// Automatically re-renders when data changes
const student = useQuery(api.students.get, { id: studentId });
No manual polling, no cache invalidation, no WebSocket management.
Use usePaginatedQuery for cursor-based pagination:
// packages/backend/convex/students.ts
export const listByCenter = query({
args: {
centerId: v.id('centers'),
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, { centerId, paginationOpts }) => {
return await ctx.db
.query('students')
.withIndex('by_center_status', (q) => q.eq('centerId', centerId))
.order('desc')
.paginate(paginationOpts);
},
});
// Client component
'use client';
import { usePaginatedQuery } from 'convex/react';
function StudentList({ centerId }: { centerId: Id<'centers'> }) {
const { results, status, loadMore } = usePaginatedQuery(
api.students.listByCenter,
{ centerId },
{ initialNumItems: 20 }
);
if (status === 'LoadingFirstPage') return <Skeleton />;
return (
<>
{results.map((s) => <StudentRow key={s._id} student={s} />)}
{status === 'CanLoadMore' && (
<button onClick={() => loadMore(20)}>Load more</button>
)}
</>
);
}
Status Values
| Status | Meaning |
|---|
LoadingFirstPage | Initial load in progress |
LoadMore | Loading additional pages |
CanLoadMore | More results available, call loadMore |
Exhausted | All results loaded |
WebRTC Signaling (Piano Mirror)
Convex subscriptions serve as a WebRTC signaling channel for P2P features. Peer A inserts offer/answer/ICE messages; peer B subscribes via useQuery and receives them in realtime.
How It Works
- Peer A calls
rooms.create or rooms.join → gets a roomId
- Peer A and B share the room
slug (e.g., “ABC123”)
- Peer A calls
signaling.sendMessage with an offer
- Peer B’s
useQuery(api.signaling.inboxFor, { roomId, since }) receives it
- Peer B responds with an answer via
signaling.sendMessage
- Both peers use the signaling exchange to establish a direct WebRTC connection
Rooms
// packages/backend/convex/rooms.ts
export const create = mutation({
args: {
slug: v.string(), // e.g. "ABC123" — shared between peers
kind: v.optional(v.union(v.literal('one-on-one'), v.literal('class'))),
displayName: v.optional(v.string()),
},
handler: async (ctx, { slug, kind, displayName }) => {
// Creates room + inserts host as first roomPeer
},
});
export const join = mutation({
args: { slug: v.string(), displayName: v.optional(v.string()) },
handler: async (ctx, { slug, displayName }) => {
// Joins existing open room by slug
},
});
export const peers = query({
args: { roomId: v.id('rooms') },
handler: async (ctx, { roomId }) => {
// Returns joined peers in the room
},
});
Signaling
// packages/backend/convex/signaling.ts
export const sendMessage = mutation({
args: {
roomId: v.id('rooms'),
toUserId: v.id('users'),
type: v.union(v.literal('offer'), v.literal('answer'), v.literal('ice')),
payload: v.any(), // SDP string for offer/answer; RTCIceCandidateInit for ice
},
handler: async (ctx, { roomId, toUserId, type, payload }) => {
// Both sender and target must be in the room
},
});
export const inboxFor = query({
args: { roomId: v.id('rooms'), since: v.number() },
handler: async (ctx, { roomId, since }) => {
// Returns messages addressed to current user since timestamp
},
});
Client Example
// Client: send an offer
const send = useMutation(api.signaling.sendMessage);
await send({
roomId,
toUserId: peerId,
type: 'offer',
payload: { sdp: pc.localDescription },
});
// Client: receive messages
const inbox = useQuery(api.signaling.inboxFor, { roomId, since: lastSeenAt });
useEffect(() => {
if (!inbox) return;
for (const msg of inbox) {
handleSignalingMessage(msg);
}
}, [inbox]);
TURN Credentials
For NAT traversal (~20% of connections need TURN relay):
// packages/backend/convex/signaling.ts
export const turnConfig = action({
args: {},
handler: async (): Promise<{ iceServers: RTCIceServer[] }> => {
// Mints ephemeral TURN creds via Cloudflare Realtime API
// Falls back to STUN-only if not configured
},
});
Set CLOUDFLARE_TURN_TOKEN_ID and CLOUDFLARE_TURN_API_TOKEN via npx convex env set.
Signaling Messages Schema
signalingMessages: defineTable({
roomId: v.id('rooms'),
fromUserId: v.id('users'),
toUserId: v.id('users'),
type: v.union(v.literal('offer'), v.literal('answer'), v.literal('ice')),
payload: v.any(),
expiresAt: v.number(),
})
.index('by_room_and_to_and_creation', ['roomId', 'toUserId'])
.index('by_expires', ['expiresAt']);
Messages auto-purge after 1 hour via the signaling.purgeExpired cron job.