Convex is a backendless platform similar to Firebase and Supabase.
I’ve tried it out and loved the developer experience—it feels super smooth and intuitive. In this post, I’ll walk you through building with Convex and highlight how it differs from other platforms.
We’ll build a simple WhatsApp clone.
Here’s the GitHub repo.
Database Schema in Code
Unlike Supabase, Convex defines the database schema entirely in code, not through a UI dashboard.
Here’s our schema.ts
file:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
clerkId: v.string(),
name: v.string(),
email: v.string(),
imageUrl: v.optional(v.string()),
lastSeen: v.optional(v.float64()),
}).index("by_clerk_id", ["clerkId"]),
conversations: defineTable({
title: v.optional(v.string()),
isGroup: v.boolean(),
lastMessageAt: v.number(),
}).index("by_last_message", ["lastMessageAt"]),
conversationParticipants: defineTable({
conversationId: v.id("conversations"),
userId: v.id("users"),
joinedAt: v.number(),
})
.index("by_conversation", ["conversationId"])
.index("by_user", ["userId"])
.index("by_conversation_and_user", ["conversationId", "userId"]),
messages: defineTable({
conversationId: v.id("conversations"),
senderId: v.id("users"),
content: v.string(),
messageType: v.union(v.literal("text"), v.literal("image"), v.literal("file")),
fileId: v.optional(v.id("_storage")),
fileName: v.optional(v.string()),
fileSize: v.optional(v.number()),
replyTo: v.optional(v.id("messages")),
editedAt: v.optional(v.number()),
})
.index("by_conversation", ["conversationId"])
});
If you’ve worked with TypeScript ORMs before, this style should feel instantly familiar.
Writing Backend Functions
Convex uses query and mutation functions that resemble tRPC endpoints—strongly typed and directly usable from the frontend.
- Queries fetch data.
- Mutations create, update, or delete data.
Here’s an example query function:
export const getMessages = query({
args: {
conversationId: v.id("conversations"),
},
returns: v.array(
v.object({
_id: v.id("messages"),
_creationTime: v.number(),
conversationId: v.id("conversations"),
senderId: v.id("users"),
content: v.string(),
messageType: v.union(v.literal("text"), v.literal("image"), v.literal("file")),
fileId: v.optional(v.id("_storage")),
fileName: v.optional(v.string()),
fileSize: v.optional(v.number()),
replyTo: v.optional(v.id("messages")),
editedAt: v.optional(v.number()),
sender: v.object({
_id: v.id("users"),
name: v.string(),
imageUrl: v.optional(v.string()),
}),
})
),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
const user = await ctx.db
.query("users")
.withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject))
.unique();
if (!user) return [];
const participation = await ctx.db
.query("conversationParticipants")
.withIndex("by_conversation_and_user", (q) =>
q.eq("conversationId", args.conversationId).eq("userId", user._id)
)
.unique();
if (!participation) return [];
const messages = await ctx.db
.query("messages")
.withIndex("by_conversation", (q) => q.eq("conversationId", args.conversationId))
.order("asc")
.collect();
const messagesWithSenders = await Promise.all(
messages.map(async (message) => {
const sender = await ctx.db.get(message.senderId);
if (!sender) return null;
return {
...message,
sender: {
_id: sender._id,
name: sender.name,
imageUrl: sender.imageUrl,
},
};
})
);
return messagesWithSenders.filter((msg): msg is NonNullable<typeof msg> => msg !== null);
},
});
Here we explicitly declare:
- Args schema (what inputs we accept)
- Return schema (what the query returns)
- Handler (the actual logic)
Using Queries in the Frontend
In React, you can consume queries with useQuery
:
const messages = useQuery(api.myFunctions.getMessages, { conversationId });
This automatically runs in real-time using WebSockets—no extra configuration required.
Mutations work similarly, using useMutation
instead.
Authentication with Clerk
Setting up auth is straightforward.
- Create a Clerk account.
- Add your
CLERK_JWT_ISSUER_DOMAIN
in the Convex project dashboard. - In your
.env
or.env.local
file, add:
CLERK_JWT_ISSUER_DOMAIN=
VITE_CLERK_PUBLISHABLE_KEY=
Convex provides prebuilt React components:
<Authenticated>
<ChatApp />
</Authenticated>
<Unauthenticated>
<SignInForm />
</Unauthenticated>
How Convex Differs from Firebase and Supabase
- Schema in code — Convex keeps schema definitions entirely within your codebase, not in a UI dashboard.
- Auth logic in backend functions — You handle authentication and authorization inside queries/mutations, rather than writing Firebase auth rules or Supabase Row Level Security policies.
For example:
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
If you’re used to building backends with Express.js or ORMs like Prisma/Drizzle, Convex will feel very natural.
AI Developer Features
Convex is AI friendly.
It supports MCP (Model Context Protocol) and includes a convex_rules.mdc
file—giving your AI coding assistant access to project-specific rules and endpoints.
Top comments (0)