category: philosophy

Why I Reach for Convex Over Supabase on AI Projects

// your agent can't read a dashboard. it can read files.

Jun 16, 2026 8 min read
🗄️ 🤖 📁

> the whole backend is just files in the repo

I like Supabase. It's a genuinely great product, it's real Postgres, and the ecosystem is enormous. But when I'm building with an AI agent in the loop — Claude Code, Cursor, whatever — I keep reaching for Convex instead. The reason is simple: your agent can't read a database dashboard. It can read files.

This isn't a "Postgres bad" post. This is a post about where your source of truth lives, and why that one decision quietly determines how well an AI can actually help you build the thing.

The real split: code vs. a live instance

With Supabase, the source of truth for your backend is a running Postgres instance. Your tables, your row-level-security policies, your functions, your triggers — they live in the database. Yes, you can manage all of it as migrations in the repo with the CLI. But the gravity of the product pulls you toward the dashboard: you click into the SQL editor, you tweak an RLS policy in the UI, you add a column, and now the thing that's true in production is not the thing that's in your git history.

With Convex, the backend is the codebase. Your schema is a TypeScript file. Your queries, mutations, actions, cron jobs, and indexes are all TypeScript files in a convex/ folder. The CLI watches those files and pushes them to the deployment. There is no second place where truth secretly lives.

The one-line version:

With Supabase, your backend is a place you connect to. With Convex, your backend is a folder you commit. For AI-assisted development, that difference is everything.

1. Your backend should be context your agent can read

An AI coding agent is only as good as the context it can see. When I open Claude Code in a Convex project and ask it to "add a likes counter to posts," it reads convex/schema.ts, sees exactly how posts is shaped, reads the existing query in convex/posts.ts, and writes a new mutation that matches the patterns already there. The entire backend is in the file tree. Nothing is hidden behind a connection string.

Now picture the same request on a Supabase project where half the schema and most of the RLS was authored in the dashboard. The agent opens the repo and finds… a client SDK call and an .env with a URL it isn't going to hit. The actual shape of your data, the policies that govern it, the functions that mutate it — none of it is on disk. The agent is flying blind, so it guesses, and you spend the afternoon correcting hallucinated column names.

Here's what "the whole backend is files" actually looks like:

// convex/schema.ts — the entire data model, in the repo
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  posts: defineTable({
    title: v.string(),
    body: v.string(),
    authorId: v.id("users"),
    likes: v.number(),
  }).index("by_author", ["authorId"]),

  users: defineTable({
    name: v.string(),
    email: v.string(),
  }).index("by_email", ["email"]),
});
// convex/posts.ts — a query and a mutation, also in the repo
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";

export const listByAuthor = query({
  args: { authorId: v.id("users") },
  handler: async (ctx, { authorId }) => {
    return await ctx.db
      .query("posts")
      .withIndex("by_author", (q) => q.eq("authorId", authorId))
      .collect();
  },
});

export const like = mutation({
  args: { postId: v.id("posts") },
  handler: async (ctx, { postId }) => {
    const post = await ctx.db.get(postId);
    if (!post) throw new Error("post not found");
    await ctx.db.patch(postId, { likes: post.likes + 1 });
  },
});

An agent can read all of that in a single pass. It knows the schema, the indexes, the validators, and the calling conventions — because they're right there. That's not a small ergonomic win; it's the difference between an agent that helps and an agent that fabricates.

2. The CLI is the entire control surface

Because everything is declared in code, the CLI is all you need to drive the backend — and so is your agent, which speaks CLI fluently.

# Start the dev deployment + a file watcher that pushes
# every change to schema and functions instantly.
npx convex dev

# Ship the current code to production.
npx convex deploy

# Run a function straight from the terminal.
npx convex run posts:like '{"postId": "..."}'

# Read/write data, manage env vars, import/export — all CLI.
npx convex data posts
npx convex env set OPENAI_API_KEY sk-...

Notice what's not in that loop: a browser. You never have to leave the terminal to change the shape of your data, and crucially, neither does your agent. When the control surface is the CLI and the definition is the code, an AI can make a change end-to-end — edit schema.ts, edit the function, run it, see the result — without a human clicking through a console it can't see.

Convex is declarative about it, too: you don't hand-write "ALTER TABLE" migrations. You change the schema file, and the running deployment reconciles to match. The deployed state is a pure function of your code. That property — code in, deployment out, no drift — is exactly the property an autonomous agent needs to work safely.

The litmus test:
Could a teammate (or an AI) recreate your entire backend from a fresh git clone and one CLI command? With Convex, yes. With a dashboard-driven Supabase project, usually not.

3. End-to-end types mean the AI writes correct code the first time

Convex generates types from your schema and functions into convex/_generated. Your frontend calls backend functions through a typed client, so a wrong argument or a renamed field is a compile error, not a 2am production surprise.

// Frontend — fully typed against the backend, no codegen step to babysit
import { useQuery, useMutation } from "convex/react";
import { api } from "../convex/_generated/api";

const posts = useQuery(api.posts.listByAuthor, { authorId });
const like = useMutation(api.posts.like);
// like({ postId }) — argument types come straight from the function def

This is a force multiplier for AI. When the agent writes a call, the type system immediately tells it (and you) whether it got the shape right. The feedback loop that catches a human's mistakes catches the model's mistakes too — and the queries are reactive by default, so the UI just updates when data changes. No manual cache wiring for the agent to get subtly wrong.

4. Billing you can actually reason about

This one matters more than people admit, especially on AI side-projects that might get a traffic spike from one viral post and then go quiet for a month.

A Supabase project is a provisioned Postgres instance. On a paid plan that instance is essentially always-on compute — you're renting a database server by the hour whether or not anyone is using it, and a real app often wants add-ons (more compute, more storage, egress) that stack up. That's a perfectly normal model for a serious production app. It's a worse fit for the "I spun up six experiments this month" reality of AI building.

Convex is usage-based and scales to zero. You're billed against function calls, database bandwidth, and storage, with a genuinely usable free tier. An idle project costs you nothing meaningful, so leaving ten prototypes deployed doesn't quietly bleed you. And because the whole thing is serverless, there's no instance to size, pause, or babysit.

Fair-play note on pricing:

Both companies move their numbers around, and both now offer spend caps so you don't get a horror-story invoice. Don't take my exact tiers as gospel — check Convex's pricing and Supabase's pricing for the current reality. The structural point holds regardless: rented always-on compute vs. usage that idles to zero.

What this looks like day-to-day with an AI

Put it together and a normal session looks like this. I tell Claude Code: "add comments to posts, with a count on each post, and a query to fetch a post with its comments." Then it:

  1. Reads convex/schema.ts and convex/posts.ts to learn the existing patterns.
  2. Adds a comments table and a commentCount field to posts, in the schema file.
  3. Writes the addComment mutation and the getWithComments query, matching the index conventions already in the repo.
  4. Wires the typed hooks into the frontend, with the type checker confirming every call.
  5. npx convex dev is already watching, so the change is live before it finishes explaining what it did.

Every step happened in files, in the terminal, inside the repo. Nothing required me to translate a dashboard into context for the model. That's the whole pitch.

The honest caveats (where Supabase wins)

I'm not going to pretend this is one-sided. Reach for Supabase when:

  • You want one integrated, batteries-included bundle. Auth, storage, realtime, and a Postgres database from a single dashboard with generous click-ops — and you're disciplined about keeping migrations and policies in the repo (Supabase absolutely supports this; it just isn't the default path of least resistance). Convex is its own document/relational hybrid, not SQL, so this is also where you'd weigh leaving SQL behind.
  • You have existing SQL infrastructure or a team that lives in SQL. Don't fight that — though see the note below on which Postgres.
  • You need the full Postgres ecosystem — complex relational queries, extensions like PostGIS and pgvector, no query-language lock-in. Real reason to skip Convex; not, by itself, a reason to pick Supabase (keep reading).

And to be fair to Supabase: a well-run Supabase project — migrations committed, RLS in version control, declarative schema via the CLI — claws back a lot of the context advantage. The problem is that "well-run" is the exception in fast AI prototyping, not the rule. Convex makes the well-run path the only path.

But if you genuinely need Postgres — reach for Neon, not Supabase:

"I need real SQL" is the strongest reason to skip Convex, but it's a weak reason to pick Supabase specifically. Pair Next.js with Neon and you get serverless Postgres that actually scales to zero, branches like git (a throwaway database per preview deploy), and a schema that lives in your repo as Drizzle or Prisma migrations — so your agent reads the data model from files, the same property that makes Convex good for AI. You define tables in code, you talk to it from typed server routes, and there's no always-on instance or dashboard-shaped source of truth. You keep the Postgres ecosystem (pgvector, extensions, plain SQL) without inheriting Supabase's "the truth lives in a running box" problem.

Bottom line

If you're building software the old way — humans typing every line — pick whichever backend you like best. But if there's an AI in the loop, optimize for the thing the AI needs most: context it can read and a control surface it can drive. A backend that lives entirely as files in your repo, pushed by a CLI, typed end-to-end, and billed only when it's used, is a backend an agent can actually reason about.

That's why, for AI projects, I reach for Convex. Try it the next time you start a prototype: npm create convex@latest, point your agent at the convex/ folder, and watch how much less it hallucinates.

🛠️ How to set this up [ show steps ▾ ]

// from empty folder to AI-ready backend in ~5 minutes

1

Scaffold the project

One command creates the app, the convex/ folder, and wires up the client.

npm create convex@latest my-app
cd my-app
2

Define your schema in code

This file is your data model — the thing your agent reads instead of guessing.

// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  tasks: defineTable({
    text: v.string(),
    done: v.boolean(),
  }),
});
3

Write a query and a mutation

Backend logic is just TypeScript files in the same folder.

// convex/tasks.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";

export const list = query({
  handler: async (ctx) => ctx.db.query("tasks").collect(),
});

export const add = mutation({
  args: { text: v.string() },
  handler: async (ctx, { text }) =>
    ctx.db.insert("tasks", { text, done: false }),
});
4

Start the dev watcher

First run logs you in and creates a dev deployment. After that it pushes every change, regenerates types, and stays live.

npx convex dev
5

Call it from the frontend (fully typed)

A wrong field name is now a compile error, not a 2am surprise.

import { useQuery, useMutation } from "convex/react";
import { api } from "../convex/_generated/api";

const tasks = useQuery(api.tasks.list);
const add = useMutation(api.tasks.add);
// add({ text: "ship it" })
6

Point your AI agent at it, then ship

Drop a one-liner in CLAUDE.md (or .cursorrules) so the agent always reads the backend, then deploy to production.

# CLAUDE.md
The entire backend lives in `convex/`. Read `convex/schema.ts`
for the data model and the function files before writing code.

# ship it
npx convex deploy

That's the whole loop — schema, functions, types, and deploys, all in the repo and all from the terminal. Full docs at docs.convex.dev.

Links

Disagree? Tell me where I'm wrong — I'll happily eat my words if your dashboard-free Supabase setup makes your agent as smart as mine.

Mann Jadwani

Mann Jadwani

GenAI Gremlin. I build things that shouldn't work, but somehow do. Currently breaking prod at 3am.