Prisma vs Convex: Picking the Right Data Layer for Next.js

Prisma vs Convex: Picking the Right Data Layer for Next.js

  • Post Author:

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:

AspectPrismaConvex
What it isTypeScript ORMBackend-as-a-Service (BaaS)
DatabaseYou bring your own (Postgres, MySQL, etc.)Convex hosts it for you
Backend logicServer Actions, API routes, or your own serverConvex functions (queries, mutations, actions)
Real-timeYou build it (WebSockets, polling, etc.)Built-in, automatic
DeploymentDatabase + app separatelyConvex 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: findManyfindUniquefindFirst with whereorderByinclude, 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

ProsCons
Vendor-agnostic: Works with Postgres, MySQL, SQLite, MongoDB, CockroachDBNo built-in real-time: I have to add WebSockets, polling, or a third-party service
Open source: No lock-in, full controlSeparate database: I manage hosting, backups, scaling
Mature ecosystem: Great docs, large communityManual cache invalidation: I must remember revalidatePath and friends
Flexible query APIwhereincludeselect, raw SQLMore boilerplate: Server Actions, API routes, or custom server
Works anywhere: Not tied to Next.js or a specific runtimeSchema migrations: I run prisma migrate for schema changes
Type-safe: Auto-generated types from schema

Convex

ProsCons
Real-time by default: UI updates when data changes, no extra codeProprietary: Vendor lock-in; leaving means a rewrite
Fully managed: No DB hosting, backups, or scaling on my plateLimited database choice: Convex database only
Reactive queriesuseQuery keeps UI in syncFilter limitations: Complex queries sometimes need indexes or workarounds
End-to-end TypeScript: Types flow from schema to frontendLearning curve: Different mental model (queries vs mutations)
Less boilerplate: No API routes or Server Actions for basic CRUDPricing: 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

CriterionPrismaConvex
Best forTraditional CRUD, flexibility, controlReal-time apps, rapid prototyping
DatabaseYour choice (Postgres, MySQL, etc.)Convex-hosted
Real-timeManualBuilt-in
Lock-inLowHigh
Setup complexityMedium (DB + ORM)Low (BaaS)
Query powerHigh (SQL-like)Good (index + filter)
CostDB hosting onlyFree 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.

we are hiring

優秀な技術者と一緒に、好きな場所で働きませんか

株式会社もばらぶでは、優秀で意欲に溢れる方を常に求めています。働く場所は自由、働く時間も柔軟に選択可能です。

現在、以下の職種を募集中です。ご興味のある方は、リンク先をご参照下さい。

コメントを残す