Convex: Your Backend, Reactive and Typed All the Way Down
If you have ever shipped a "simple" realtime feature, you know the truth: it is never simple. A live chat, a collaborative dashboard, or a multiplayer cursor needs a database, an API layer, a websocket channel, a cache with some invalidation strategy, and a way to keep your server and client types from drifting apart. Each of those seams is a place for bugs to hide.
Convex takes a different swing. It is a reactive backend-as-a-service that bundles three things you would normally wire together by hand: a document database, a serverless function runtime where you write backend logic in pure TypeScript, and a reactive query engine that pushes live updates to subscribed clients over a websocket. You write typed functions, and the convex/react client keeps your UI in sync automatically, with end-to-end type safety running from the database all the way into your components. The reactive engine and database core are written in Rust, while your application logic stays comfortably in TypeScript.
It is a great fit for collaborative and live apps such as chat, dashboards, multiplayer experiences, and AI apps that need vector search, especially when a TypeScript-centric team would rather write functions than provision infrastructure.
What Makes It Click
Convex is built around a small set of ideas that reinforce each other:
- Live-updating queries. Any
useQuerysubscription re-renders automatically when the underlying data changes. You never write sync logic, polling loops, or manual cache invalidation. - End-to-end types. Your
schema.tsand function signatures generate a typedapiobject consumed directly in React. There is no codegen drift and no hand-typed fetch responses. - Transactional consistency. Queries and mutations run as transactions with serializable, strongly consistent semantics using optimistic concurrency control, sidestepping the partial-read and stale-cache problems common in Firebase-style stores.
- Batteries included. Scheduling and cron jobs, file storage, auth integrations (Clerk, Auth0, custom OIDC, or first-party Convex Auth), full-text search, and built-in vector search for RAG and semantic search.
- An open-source escape hatch. The
convex-backendrepo is the same Rust engine that powers the hosted cloud, and it is self-hostable via Docker or prebuilt binaries.
The mental model that ties it together is a deliberate split of backend logic into three function types. Queries are read-only, deterministic, cached, and reactive. Mutations are transactional writes that run atomically. Actions are the escape hatch for anything non-deterministic, such as calling Stripe or OpenAI, after which they call mutations to persist results. Pure reactive reads, atomic writes, and side-effectful glue, each with its own guarantees.
Getting It Into Your Project
Install the convex package with your package manager of choice:
npm install convex
yarn add convex
Then run the development setup, which provisions a project, generates the convex/ directory, and starts the live function sync:
npx convex dev
This command also generates the typed _generated folder that your React code imports, so keep it running while you develop.
Wiring Up the Client
Everything starts with a ConvexReactClient pointed at your deployment URL, wrapped around your app with a ConvexProvider. This is what opens and manages the websocket that powers live updates.
import { ConvexProvider, ConvexReactClient } from "convex/react";
const client = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
function Root() {
return (
<ConvexProvider client={client}>
<App />
</ConvexProvider>
);
}
That single provider is the entire client-side bootstrap. Every hook beneath it shares the same connection, subscriptions, and consistency guarantees.
Describing Your Data
Schema in Convex is optional but strongly recommended, because it is what drives the end-to-end type generation. You define tables with defineTable, describe fields with the v validator builder, and declare indexes for the lookups you care about.
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
messages: defineTable({
body: v.string(),
user: v.id("users"),
}),
users: defineTable({
name: v.string(),
tokenIdentifier: v.string(),
}).index("by_token", ["tokenIdentifier"]),
});
The v builder supports strings, numbers, unions, optionals, nested objects, and references to other tables via v.id. Because Convex is a document store rather than a relational database, joins are manual: you store IDs and look them up through indexes like by_token, which keeps your read-sets narrow and your queries fast.
Reading and Writing Data
A query is a read-only function that the engine watches. It records exactly which data it touched, so when a mutation later changes that data, only the affected queries are recomputed and pushed to clients.
import { query } from "./_generated/server";
import { v } from "convex/values";
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("messages").collect();
},
});
export const getTask = query({
args: { id: v.id("tasks") },
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
},
});
Mutations are the transactional counterpart. They insert, update, and delete atomically with serializable consistency, so concurrent writers never leave your data in a half-written state.
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const send = mutation({
args: { body: v.string(), user: v.id("users") },
handler: async (ctx, args) => {
const id = await ctx.db.insert("messages", {
body: args.body,
user: args.user,
});
return id;
},
});
Both query and mutation handlers must be deterministic. No fetch, no randomness, no branching on Date.now(). That constraint is exactly what lets the engine cache, reason about, and replay them safely.
Bringing It To React
Now the payoff. In a component, useQuery subscribes to a query and useMutation returns a callable writer. The api object is generated from your function files, so api.messages.list is fully typed and the return value of useQuery is inferred for you.
import { useQuery, useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
export function Chat({ someUserId }: { someUserId: string }) {
const messages = useQuery(api.messages.list);
const send = useMutation(api.messages.send);
return (
<div>
{messages?.map((m) => <p key={m._id}>{m.body}</p>)}
<button onClick={() => send({ body: "hi", user: someUserId })}>
Send
</button>
</div>
);
}
messages is undefined while loading and then becomes the typed array. When anyone else sends a message, the underlying data changes, the engine recomputes list, and your component re-renders with the new value. You did not write a single line of subscription or cache code to make that happen.
Reaching Beyond the Database
Queries and mutations are sealed off from the outside world by design, so when you need to talk to a third-party service you reach for an action. Actions are not transactional and not reactive, but they can call external APIs and then invoke mutations to write results back.
import { action } from "./_generated/server";
import { v } from "convex/values";
import { api } from "./_generated/api";
export const summarize = action({
args: { messageId: v.id("messages") },
handler: async (ctx, args) => {
const message = await ctx.runQuery(api.messages.getTask, {
id: args.messageId,
});
const res = await fetch("https://api.example-llm.com/summarize", {
method: "POST",
body: JSON.stringify({ text: message?.body }),
});
const { summary } = await res.json();
await ctx.runMutation(api.messages.attachSummary, {
id: args.messageId,
summary,
});
},
});
This three-way split is the main learning curve, but it pays off: your reactive, cacheable core stays pure, and all the messy non-determinism lives in one clearly labeled place.
Scheduling Work and Searching Smartly
Convex includes a scheduler for running functions later or on a recurring cadence. From inside any mutation or action you can enqueue future work with ctx.scheduler, and you can register recurring jobs in a crons.ts file.
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
crons.daily(
"cleanup expired sessions",
{ hourUTC: 3, minuteUTC: 0 },
internal.sessions.cleanup,
);
export default crons;
For search-heavy and AI workloads, Convex offers both full-text search indexes and built-in vector indexes. You declare them on a table the same way you declare a regular index, then query them from inside a function, which makes semantic search and retrieval-augmented generation a first-class part of the same typed codebase rather than a separate service to bolt on.
Optimistic Updates for Snappy UIs
When you want a mutation to feel instant, the client supports optimistic updates that apply a local change immediately and reconcile with server state once the write commits.
const send = useMutation(api.messages.send).withOptimisticUpdate(
(localStore, args) => {
const existing = localStore.getQuery(api.messages.list) ?? [];
localStore.setQuery(api.messages.list, {}, [
...existing,
{ _id: crypto.randomUUID(), body: args.body, user: args.user } as any,
]);
},
);
Because the optimistic value lives in the same reactive store as your real queries, the UI updates instantly and then settles to the authoritative result without flicker or manual rollback logic.
How It Stacks Up
Convex sits closest to Firebase in spirit, sharing query-level reactivity and a document store, but it wins on TypeScript-first server logic, strong transactional consistency, and end-to-end types. If you need true relational power with joins and ad-hoc SQL, Supabase and its Postgres core are the better tool, since Supabase watches the write-ahead log for realtime while Convex watches the query result itself. Client-first sync engines like InstantDB or Rocicorp Zero optimize for local-first, zero-latency reads, whereas Convex centers on server-side transactional functions in a unified hosted backend. And because the Rust engine is open source and self-hostable, the usual "backend-as-a-service lock-in" worry is materially de-risked.
Wrapping Up
Convex is a bet that most of the plumbing you write for realtime apps does not need to exist. By unifying a document database, a TypeScript function runtime, and a reactive query engine, it lets you describe your data, write a handful of typed functions, and get live-updating, strongly consistent, fully typed React UIs almost for free. The query, mutation, and action split takes a moment to internalize, and shifting from SQL to a document model with manual joins is a real adjustment, but in return you delete an enormous amount of glue code. If you are a TypeScript-centric team building something collaborative, live, or AI-flavored, and you would rather write functions than wire up infrastructure, Convex is well worth a look.