Convex Components package up code and data in a sandbox that allows you to confidently and quickly add new features to your backend.
This skill inherits all available tools. When active, it can use any tool Claude has access to.
Convex Components package up code and data in a sandbox that allows you to confidently and quickly add new features to your backend.
convex/convex.config.ts file that includes the componentA Convex component for managing presence functionality - a live-updating list of users in a "room" including their status for when they were last online.
It can be tricky to implement presence efficiently without polling and without re-running queries every time a user sends a heartbeat message. This component implements presence via Convex scheduled functions such that clients only receive updates when a user joins or leaves the room.
npm install @convex-dev/presence
Add the component to your Convex app in convex/convex.config.ts:
import { defineApp } from "convex/server";
import presence from "@convex-dev/presence/convex.config";
const app = defineApp();
app.use(presence);
export default app;
Create convex/presence.ts:
import { mutation, query } from "./_generated/server";
import { components } from "./_generated/api";
import { v } from "convex/values";
import { Presence } from "@convex-dev/presence";
import { getAuthUserId } from "@convex-dev/auth/server";
import { Id } from "./_generated/dataModel";
export const presence = new Presence(components.presence);
export const getUserId = query({
args: {},
returns: v.union(v.string(), v.null()),
handler: async (ctx) => {
return await getAuthUserId(ctx);
},
});
export const heartbeat = mutation({
args: {
roomId: v.string(),
userId: v.string(),
sessionId: v.string(),
interval: v.number(),
},
returns: v.string(),
handler: async (ctx, { roomId, userId, sessionId, interval }) => {
const authUserId = await getAuthUserId(ctx);
if (!authUserId) {
throw new Error("Not authenticated");
}
return await presence.heartbeat(ctx, roomId, authUserId, sessionId, interval);
},
});
export const list = query({
args: { roomToken: v.string() },
handler: async (ctx, { roomToken }) => {
const presenceList = await presence.list(ctx, roomToken);
const listWithUserInfo = await Promise.all(
presenceList.map(async (entry) => {
const user = await ctx.db.get(entry.userId as Id<"users">);
if (!user) {
return entry;
}
return {
...entry,
name: user?.name,
image: user?.image,
};
})
);
return listWithUserInfo;
},
});
export const disconnect = mutation({
args: { sessionToken: v.string() },
returns: v.null(),
handler: async (ctx, { sessionToken }) => {
await presence.disconnect(ctx, sessionToken);
return null;
},
});
// src/App.tsx
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
import usePresence from "@convex-dev/presence/react";
import FacePile from "@convex-dev/presence/facepile";
export default function App(): React.ReactElement {
const userId = useQuery(api.presence.getUserId);
return (
<main>
{userId && <PresenceIndicator userId={userId} />}
</main>
);
}
function PresenceIndicator({ userId }: { userId: string }) {
const presenceState = usePresence(api.presence, "my-chat-room", userId);
return <FacePile presenceState={presenceState ?? []} />;
}
export default function usePresence(
presence: PresenceAPI,
roomId: string,
userId: string,
interval: number = 10000,
convexUrl?: string
): PresenceState[] | undefined
ALWAYS use the FacePile UI component included with this package unless the user explicitly requests a custom presence UI. You can copy the code and use the usePresence hook directly to implement your own styling.
A Convex Component that syncs a ProseMirror document between clients via a Tiptap extension (also works with BlockNote).
npm install @convex-dev/prosemirror-sync
Create convex/convex.config.ts:
import { defineApp } from 'convex/server';
import prosemirrorSync from '@convex-dev/prosemirror-sync/convex.config';
const app = defineApp();
app.use(prosemirrorSync);
export default app;
IMPORTANT: You do NOT need to add component tables to your schema.ts. The component tables are only read and written to from the component functions.
Create convex/prosemirror.ts:
import { components } from './_generated/api';
import { ProsemirrorSync } from '@convex-dev/prosemirror-sync';
import { DataModel } from "./_generated/dataModel";
import { GenericQueryCtx, GenericMutationCtx } from 'convex/server';
import { getAuthUserId } from "@convex-dev/auth/server";
const prosemirrorSync = new ProsemirrorSync(components.prosemirrorSync);
// Optional: Define permission checks
async function checkPermissions(ctx: GenericQueryCtx<DataModel>, id: string) {
const userId = await getAuthUserId(ctx);
if (!userId) {
throw new Error("Unauthorized");
}
// Add your own authorization logic here
}
export const {
getSnapshot,
submitSnapshot,
latestVersion,
getSteps,
submitSteps
} = prosemirrorSync.syncApi<DataModel>({
checkRead: checkPermissions,
checkWrite: checkPermissions,
onSnapshot: async (ctx, id, snapshot, version) => {
// Optional: Called when a new snapshot is available
console.log(`Document ${id} updated to version ${version}`);
},
});
IMPORTANT: Do NOT use any other component functions outside the functions exposed by prosemirrorSync.syncApi.
// src/MyComponent.tsx
import { useBlockNoteSync } from '@convex-dev/prosemirror-sync/blocknote';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { api } from '../convex/_generated/api';
import { BlockNoteEditor } from '@blocknote/core';
function MyComponent({ id }: { id: string }) {
const sync = useBlockNoteSync<BlockNoteEditor>(api.prosemirror, id);
return sync.isLoading ? (
<p>Loading...</p>
) : sync.editor ? (
<BlockNoteView editor={sync.editor} />
) : (
<button onClick={() => sync.create({ type: 'doc', content: [] })}>
Create document
</button>
);
}
// IMPORTANT: Wrapper to re-render when id changes
export function MyComponentWrapper({ id }: { id: string }) {
return <MyComponent key={id} id={id} />;
}
The sync.create function accepts an argument with JSONContent type. Do NOT pass it a string - it must be an object:
export type JSONContent = {
type?: string;
attrs?: Record<string, any>;
content?: JSONContent[];
marks?: {
type: string;
attrs?: Record<string, any>;
[key: string]: any;
}[];
text?: string;
[key: string]: any;
};
Empty document example:
sync.create({ type: 'doc', content: [] })
The snapshot debounce interval is set to one second by default. You can specify a different interval with the snapshotDebounceMs option when calling useBlockNoteSync.
A snapshot won't be sent until:
The official way to integrate the Resend email service with your Convex project.
npm install @convex-dev/resend
RESEND_API_KEYRESEND_DOMAINCreate or update convex/convex.config.ts:
import { defineApp } from "convex/server";
import resend from "@convex-dev/resend/convex.config";
const app = defineApp();
app.use(resend);
export default app;
Create convex/sendEmails.ts:
import { components, internal } from "./_generated/api";
import { Resend, vEmailId, vEmailEvent } from "@convex-dev/resend";
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";
export const resend: Resend = new Resend(components.resend, {
// Set testMode: false to send to real addresses (default is true)
testMode: true,
onEmailEvent: internal.sendEmails.handleEmailEvent,
});
export const sendTestEmail = internalMutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
await resend.sendEmail(
ctx,
`Me <test@${process.env.RESEND_DOMAIN}>`,
"Resend <delivered@resend.dev>",
"Hi there",
"This is a test email"
);
return null;
},
});
// Handle email status events (requires webhook setup)
export const handleEmailEvent = internalMutation({
args: {
id: vEmailId,
event: vEmailEvent,
},
returns: v.null(),
handler: async (ctx, args) => {
console.log("Email event:", args.id, args.event);
// Handle the event (delivered, bounced, etc.)
return null;
},
});
Add to convex/http.ts:
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { resend } from "./sendEmails";
const http = httpRouter();
http.route({
path: "/resend-webhook",
method: "POST",
handler: httpAction(async (ctx, req) => {
return await resend.handleResendEventWebhook(ctx, req);
}),
});
export default http;
Your webhook URL will be: https://<deployment-name>.convex.site/resend-webhook
Configure the webhook in Resend dashboard:
email.* eventsRESEND_WEBHOOK_SECRET environment variableconst resend = new Resend(components.resend, {
// Provide API key instead of environment variable
apiKey: "your-api-key",
// Provide webhook secret instead of environment variable
webhookSecret: "your-webhook-secret",
// Only allow delivery to test addresses (default: true)
// Set to false for production
testMode: false,
// Your email event callback
onEmailEvent: internal.sendEmails.handleEmailEvent,
});
// sendEmail returns an EmailId
const emailId = await resend.sendEmail(ctx, from, to, subject, body);
// Check status
const status = await resend.status(ctx, emailId);
// Cancel email (only if not yet sent)
await resend.cancelEmail(ctx, emailId);
This component retains "finalized" (delivered, cancelled, bounced) emails for seven days to allow status checks. Then, a background job clears those emails and their bodies to reclaim database space.
You can use multiple components in the same convex.config.ts:
import { defineApp } from "convex/server";
import presence from "@convex-dev/presence/convex.config";
import prosemirrorSync from "@convex-dev/prosemirror-sync/convex.config";
import resend from "@convex-dev/resend/convex.config";
const app = defineApp();
app.use(presence);
app.use(prosemirrorSync);
app.use(resend);
export default app;
Component functions are accessed via components.<component_name>.<function> imported from ./_generated/api:
import { components } from "./_generated/api";