Why I keep reaching for Convex

I've been building with Convex on two projects recently and I want to write down why I find it genuinely interesting. Not a tutorial nor a pitch, just what actually clicked for me.
The two projects
The first one is a booking platform for a parachute school. Users book tandem jumps, choose a date, pay online (with a deposit-then-balance flow), fill out a health questionnaire, and get reminded if they haven't settled their balance yet. On the business side: Stripe webhooks, scheduled cron jobs, Cal.com integration for available slots, and Nodemailer for emails.
The second is an AI photo generator. Users upload a few reference photos of their face, pick a scene template ("beach at sunset", "rooftop bar", "gym"), and Google Gemini generates a photorealistic image of them in that scene. It has a credits system, Stripe subscriptions, and real-time generation status.
Very different products. Both ended up on Convex. And both times, it was the right call.
The convex/ folder is your whole backend
This is the thing that clicked first.
In both projects, everything that runs on the server lives in one folder: convex/. Schema. Auth configuration. Business logic. Stripe webhook handlers. Cron jobs. File storage. HTTP routes. All of it. In the parachute school project:
convex/
schema.ts ← all data models (bookings, gift cards, health questionnaires...)
bookings.ts ← booking mutations and queries
stripe.ts ← Stripe webhook handler (action)
crons.ts ← scheduled jobs
http.ts ← HTTP routes
No separate backend to configure. No dashboard with hidden settings you'll forget about. No Terraform. No environment-specific RLS rules buried somewhere.
The deployment command for the AI photo generator is literally:
convex deploy --cmd 'npm run build'
One git push ships the full stack. A new contributor clones the repo, runs npx convex dev, and has a fully isolated dev backend in 30 seconds — with their own copy of the database. No shared dev database collisions.
I didn't fully appreciate this until I was onboarding myself into my own parachute school codebase two months after writing it. I opened the convex/ folder and understood everything immediately. There was nowhere else to look.
Type safety from the schema all the way to the UI
In the AI photo generator schema, the reference photo slot is defined as:
slot: v.union(
v.literal('selfie'),
v.literal('smiling'),
v.literal('angle45'),
v.literal('side'),
v.literal('full_body'),
);If I mistype "smiiling" anywhere in a mutation, TypeScript catches it before it reaches prod. Same for generation status — v.literal("done") means a typo can't slip through at runtime.
And because Convex generates types from your backend functions, when the frontend calls useQuery(api.generations.list), it knows exactly what shape comes back. No codegen step I have to remember to run. No type drift between server and client. If I change the return type of a query, the TypeScript errors appear directly in the component consuming it — not in the query file, where they'd be useless.
This is the same magic that made TRPC so good. Convex just bakes it in by default.
The reactivity is not a gimmick
The demo that usually sells people on Convex is changing a value directly in the dashboard and watching the UI update in real time. Cool parlor trick, but the actual value shows up in prod.
In the AI photo generator, generations take a while. The user submits a set of photos, picks scenes, and waits. The generation status goes pending → done (or error). In the frontend:
const generations = useQuery(api.generations.listByUser, { userId });When a Gemini call finishes and a mutation flips the status to done, every subscribed client sees their gallery update instantly. No polling. No WebSocket wiring. No invalidateQuery() call. The user just watches their photo appear.
In the parachute school project, I can toggle a preset's active: false directly in the Convex dashboard during a demo and it disappears from the booking UI in real time for all connected users. No cache invalidation, no deployment. It just works.
You get used to it fast. Then you go back to writing manual refetch logic in a different project and you feel it.
Mutations are transactions. Actions are for the outside world.
This distinction took me one project to fully internalize and it's worth naming.
Queries and mutations are pure. They run on Convex's runtime, have full read-write access to the database, and are automatically transactional. If a mutation fails halfway through, nothing commits. In the parachute school project, the confirmPayment mutation handles three cases — deposit, remaining balance, full payment — in one function. If Stripe sends a webhook and something fails midway, the booking never ends up in a half-paid state.
Actions are for touching the outside world: Stripe API calls, sending emails, calling Gemini. They can call mutations, but they don't have direct DB access during the external call. The separation is explicit in the code, not a convention I have to remember.
// stripe.ts — action because it calls Stripe
export const createCheckout = action({
handler: async (ctx, args) => {
const session = await stripe.checkout.sessions.create({ ... });
await ctx.runMutation(api.credits.addCredits, { ... }); // ← back to the DB
}
})The mental model is clean. Queries and mutations for pure DB work. Actions for anything touching external services.
When not to use it
Convex is wrong if:
- You have multiple separate backends (Go, Rust, whatever) that need to share the same database. The database lives in your TypeScript codebase. That's a feature for single-stack apps and a limitation for everything else.
- A data team needs raw SQL access for analytics. Convex is an application database, not a data warehouse. The answer there is to fire-hose your data into ClickHouse or Postgres and let them run their queries there.
Both projects are single TypeScript stacks with one frontend and one backend. That's exactly the sweet spot.
The honest version
I'm not saying Convex is perfect. The useQuery hook naming collides with React Query's, which creates confusion in mixed codebases... But I get that they were inspired by React Query, so it makes sense 😅 Also, the skip behavior is a magic string instead of a config object. Error handling throws instead of returning structured errors.
But these are annoyances, not blockers. The things that matter — type safety, reactivity, the transaction model, the zero-ops deployment — all work exactly as advertised. Every time I was convinced something was broken, it turned out I was wrong.
Two projects in, it's the first tool I reach for when I'm starting something new in TypeScript.
If your whole backend is one TypeScript app, there's very little reason to reach for anything else.