DEV Community

Cover image for Convex - an alternative to Firebase and Supabase
Noyan Alim
Noyan Alim

Posted on

Convex - an alternative to Firebase and Supabase

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"])
});
Enter fullscreen mode Exit fullscreen mode

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);
  },
});
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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.

  1. Create a Clerk account.
  2. Add your CLERK_JWT_ISSUER_DOMAIN in the Convex project dashboard.
  3. In your .env or .env.local file, add:
CLERK_JWT_ISSUER_DOMAIN=
VITE_CLERK_PUBLISHABLE_KEY=
Enter fullscreen mode Exit fullscreen mode

Convex provides prebuilt React components:

<Authenticated>
  <ChatApp />
</Authenticated>
<Unauthenticated>
  <SignInForm />
</Unauthenticated>
Enter fullscreen mode Exit fullscreen mode

How Convex Differs from Firebase and Supabase

  1. Schema in code — Convex keeps schema definitions entirely within your codebase, not in a UI dashboard.
  2. 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 [];
Enter fullscreen mode Exit fullscreen mode

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)