I’ve been working with Next.js since v11, all the way through to the latest v16. Prisma has been my go-to for data access for most of that journey. When Convex started gaining traction, I decided to try it and see how it stacks up: performance, developer experience, and how it compares to what I’m used to with Prisma.
This guide is what came out of that experiment: a practical CRUD comparison, the pros and cons I’ve run into, and when I’d pick one over the other.
How I Think About Them
Before we dive into code, here’s the way I frame it:
| Aspect | Prisma | Convex |
|---|---|---|
| What it is | TypeScript ORM | Backend-as-a-Service (BaaS) |
| Database | You bring your own (Postgres, MySQL, etc.) | Convex hosts it for you |
| Backend logic | Server Actions, API routes, or your own server | Convex functions (queries, mutations, actions) |
| Real-time | You build it (WebSockets, polling, etc.) | Built-in, automatic |
| Deployment | Database + app separately | Convex cloud + your app |
Prisma is a type-safe ORM that connects to whatever database you choose. Convex is a full backend (database, server functions, real-time) all in one. That difference shapes everything.
Schema: Where It Starts
Prisma
I define my models in prisma/schema.prisma:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Post {
id String @id @default(cuid())
title String
content String?
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Then I run npx prisma generate and npx prisma db push (or migrate) to sync. Migrations are part of the workflow. I’ve learned to embrace them.
Convex
With Convex, I define tables in convex/schema.ts:
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
posts: defineTable({
title: v.string(),
content: v.optional(v.string()),
published: v.boolean(),
}).index("by_published", ["published"]),
});
No separate migration step during development. Schema changes just work. I found this nice when iterating quickly.
CRUD: Side-by-Side
I’ll use a simple Post model for all examples so you can compare directly.
Create
Prisma
I typically use a Server Action ('use server') or an API route:
// lib/actions.ts
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
const published = formData.get("published") === "on";
const post = await prisma.post.create({
data: {
title,
content: content || null,
published: published ?? false,
},
});
revalidatePath("/posts");
return post;
}
The pattern: imperative create() with a data object. One thing I always have to remember: call revalidatePath() (or similar) so the UI actually refreshes. Easy to forget.
Convex
I define a mutation in convex/posts.ts:
// convex/posts.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const create = mutation({
args: {
title: v.string(),
content: v.optional(v.string()),
published: v.boolean(),
},
handler: async (ctx, args) => {
return await ctx.db.insert("posts", {
title: args.title,
content: args.content,
published: args.published ?? false,
});
},
});
Declarative mutation with validated args. Returns the new document ID. And here’s the part I really like: the UI updates automatically. No revalidatePath, no manual invalidation. Convex’s reactive subscriptions handle it.
The difference that matters to me: With Prisma, I’m in charge of cache invalidation. With Convex, data changes flow to the UI on their own.
Read
Prisma
List posts:
// lib/actions.ts or Server Component
const posts = await prisma.post.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
take: 20,
});
Get one by ID:
const post = await prisma.post.findUnique({
where: { id },
});
I love Prisma’s query API: findMany, findUnique, findFirst with where, orderBy, include, and so on. It feels SQL-like and flexible. When I need complex joins or raw SQL, Prisma has my back.
Convex
List posts (query):
// convex/posts.ts
import { query } from "./_generated/server";
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db
.query("posts")
.withIndex("by_published", (q) => q.eq("published", true))
.order("desc")
.take(20);
},
});
Get one by ID:
export const get = query({
args: { id: v.id("posts") },
handler: async (ctx, args) => {
return await ctx.db.get("posts", args.id);
},
});
In a React component:
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
const posts = useQuery(api.posts.list);
Queries are reactive by default. useQuery re-runs when data changes. I use withIndex() when I have a matching index (like by_published); it’s more efficient than filter(), which scans the table. For order("desc"), Convex sorts by the index fields and then by _creationTime, so newest posts come first.
The difference: Prisma gives me a rich, SQL-inspired API. Convex gives me reactive queries that keep the UI in sync without extra code.
Update
Prisma
const post = await prisma.post.update({
where: { id },
data: {
title: newTitle,
content: newContent,
published: true,
},
});
Straightforward: update() with where and data. I use updateMany when I need batch updates.
Convex
// convex/posts.ts
export const update = mutation({
args: {
id: v.id("posts"),
title: v.optional(v.string()),
content: v.optional(v.string()),
published: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const { id, ...rest } = args;
const updates = Object.fromEntries(
Object.entries(rest).filter(([, v]) => v !== undefined)
);
await ctx.db.patch("posts", id, updates);
},
});
patch() does a shallow merge. Omit a field and it stays unchanged; set it to undefined and it gets removed. For full replacement I’d use replace().
The difference: Prisma’s update is explicit and SQL-like. Convex’s patch is document-oriented and partial by default, which is nice when I’m only changing a few fields.
Delete
Prisma
await prisma.post.delete({
where: { id },
});
Supports deleteMany for batch deletes. Simple.
Convex
// convex/posts.ts
export const remove = mutation({
args: { id: v.id("posts") },
handler: async (ctx, args) => {
await ctx.db.delete("posts", args.id);
},
});
delete(table, id). Equally simple.
Both are straightforward. Prisma lets me use complex where conditions; Convex typically deletes by ID.
Pros and Cons (From My Experience)
Prisma
| Pros | Cons |
|---|---|
| Vendor-agnostic: Works with Postgres, MySQL, SQLite, MongoDB, CockroachDB | No built-in real-time: I have to add WebSockets, polling, or a third-party service |
| Open source: No lock-in, full control | Separate database: I manage hosting, backups, scaling |
| Mature ecosystem: Great docs, large community | Manual cache invalidation: I must remember revalidatePath and friends |
Flexible query API: where, include, select, raw SQL | More boilerplate: Server Actions, API routes, or custom server |
| Works anywhere: Not tied to Next.js or a specific runtime | Schema migrations: I run prisma migrate for schema changes |
| Type-safe: Auto-generated types from schema |
Convex
| Pros | Cons |
|---|---|
| Real-time by default: UI updates when data changes, no extra code | Proprietary: Vendor lock-in; leaving means a rewrite |
| Fully managed: No DB hosting, backups, or scaling on my plate | Limited database choice: Convex database only |
Reactive queries: useQuery keeps UI in sync | Filter limitations: Complex queries sometimes need indexes or workarounds |
| End-to-end TypeScript: Types flow from schema to frontend | Learning curve: Different mental model (queries vs mutations) |
| Less boilerplate: No API routes or Server Actions for basic CRUD | Pricing: Free tier; paid plans for production scale |
| Fast iteration: Schema changes without migrations in dev |
When I Choose Each
I reach for Prisma when:
- I need database flexibility: I want Postgres, MySQL, or something else, and the ability to switch later.
- I’m building traditional request-response apps: Dashboards, CRUD, content sites where real-time isn’t critical.
- I want to avoid vendor lock-in: Open source and self-hosted matter to me or my team.
- I have existing infrastructure: There’s already a database; I just want a type-safe ORM.
- I need complex queries: Joins, aggregations, raw SQL, or advanced filtering are central.
- I’m on a tight budget: Prisma is free; I only pay for database hosting.
I reach for Convex when:
- Real-time is core to the product: Chat, collaborative editing, live dashboards, notifications.
- I want to move fast: Minimal setup, no database or migration management.
- I prefer a managed backend: I’d rather focus on product than DB ops.
- I’m starting a new app: No existing database to integrate.
- I value reactive UX: I want the UI to reflect data changes without manual invalidation.
- I’m okay with a proprietary stack: I accept Convex as my backend provider.
Quick Reference
| Criterion | Prisma | Convex |
|---|---|---|
| Best for | Traditional CRUD, flexibility, control | Real-time apps, rapid prototyping |
| Database | Your choice (Postgres, MySQL, etc.) | Convex-hosted |
| Real-time | Manual | Built-in |
| Lock-in | Low | High |
| Setup complexity | Medium (DB + ORM) | Low (BaaS) |
| Query power | High (SQL-like) | Good (index + filter) |
| Cost | DB hosting only | Free tier + paid plans |
Final Thoughts
Prisma and Convex solve different problems. I use Prisma when I want a powerful, flexible ORM with database independence and full control. I use Convex when I want a managed, real-time backend with minimal setup and automatic UI reactivity.
For most Next.js projects I work on, the decision comes down to one question: Do I need real-time and managed infrastructure, or do I need database flexibility and control? Answer that, and the choice becomes clear.
If you’re on the fence, try both on a small side project. I did, and it helped me understand where each one shines.