Convex: Backend Without the Backlog
I replaced Postgres, Redis, WebSockets, and three microservices with 200 lines of TypeScript. Here’s exactly how.
Convex: Backend Without the Backlog
I replaced Postgres, Redis, WebSockets, and three microservices with 200 lines of TypeScript. Here’s exactly how.

Introduction
I’ve been building web applications for over a decade. In that time, I’ve set up countless PostgreSQL instances, wrestled with Redis caching layers, debugged WebSocket connection issues at 2 AM, and written more boilerplate CRUD operations than I care to remember.
Then I found Convex, and it changed how I think about backend development.
Convex isn’t just another Backend-as-a-Service. It’s a paradigm shift that combines your database, real-time sync layer, server functions, file storage, and background jobs into a single, coherent platform — all with end-to-end TypeScript type safety.
In this guide, I’ll show you not just how to get started, but how to solve the real engineering problems you’ll face in production: authentication, multi-tenancy, rate limiting, file uploads, search, and more.

What Makes Convex Different?
The Problem with Traditional Backends
Building a modern application typically requires:
- Database — PostgreSQL, MySQL, MongoDB
- ORM/Query Builder — Prisma, Drizzle, TypeORM
- API Layer — REST endpoints or GraphQL resolvers
- Real-time — WebSockets, Socket.io, Pusher
- Caching — Redis, Memcached
- Background Jobs — Bull, Agenda, custom workers
- File Storage — S3, Cloudinary
- Authentication — Passport, custom JWT logic
Each layer introduces complexity, potential bugs, and maintenance burden. Keeping them in sync is a full-time job that nobody signed up for.
The Convex Approach
Convex collapses all of this into a single platform:
[embed]
The key insight: Every query in Convex is automatically a real-time subscription. When data changes, all subscribed clients update instantly. No WebSocket code. No cache invalidation logic.
Quick Start: Your First Convex App
Let’s build something real — a collaborative task manager with real-time sync.
Prerequisites
- Node.js 18+
- A free Convex account at convex.dev
Step 1: Create Your Project
npm create convex@latest my-task-app
cd my-task-app
npm run dev
This starts both your frontend and syncs your Convex functions to the cloud.
Step 2: Define a Production-Ready Schema
// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
export default defineSchema({
tasks: defineTable({
title: v.string(),
description: v.optional(v.string()),
status: v.union(
v.literal('todo'),
v.literal('in_progress'),
v.literal('done'),
),
priority: v.union(v.literal('low'), v.literal('medium'), v.literal('high')),
assigneeId: v.optional(v.id('users')),
projectId: v.id('projects'),
dueDate: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_project', ['projectId'])
.index('by_assignee', ['assigneeId'])
.index('by_status', ['status'])
.index('by_project_status', ['projectId', 'status'])
.searchIndex('search_title', {
searchField: 'title',
filterFields: ['projectId', 'status'],
}),
projects: defineTable({
name: v.string(),
description: v.optional(v.string()),
ownerId: v.string(),
createdAt: v.number(),
}).index('by_owner', ['ownerId']),
users: defineTable({
clerkId: v.string(),
email: v.string(),
name: v.string(),
avatarUrl: v.optional(v.string()),
}).index('by_clerk_id', ['clerkId']),
});
Key points:
v.*validators provide runtime type checking AND compile-time TypeScript types- Indexes are required for efficient queries — plan them based on your query patterns
- Compound indexes (
by_project_status) enable multi-field filtering - Search indexes enable full-text search with filters
Step 3: Write Production-Quality Functions
// convex/tasks.ts
import { query, mutation } from './_generated/server';
import { v } from 'convex/values';
// List tasks with filtering and related data
export const list = query({
args: {
projectId: v.id('projects'),
status: v.optional(
v.union(v.literal('todo'), v.literal('in_progress'), v.literal('done')),
),
},
handler: async (ctx, args) => {
// Use compound index when filtering by both project and status
const tasks = args.status
? await ctx.db
.query('tasks')
.withIndex('by_project_status', (q) =>
q.eq('projectId', args.projectId).eq('status', args.status!),
)
.order('desc')
.collect()
: await ctx.db
.query('tasks')
.withIndex('by_project', (q) => q.eq('projectId', args.projectId))
.order('desc')
.collect();
// Fetch assignee details in parallel
return Promise.all(
tasks.map(async (task) => {
const assignee = task.assigneeId
? await ctx.db.get(task.assigneeId)
: null;
return { ...task, assignee };
}),
);
},
});
// Create a new task
export const create = mutation({
args: {
title: v.string(),
description: v.optional(v.string()),
projectId: v.id('projects'),
priority: v.union(v.literal('low'), v.literal('medium'), v.literal('high')),
assigneeId: v.optional(v.id('users')),
dueDate: v.optional(v.number()),
},
handler: async (ctx, args) => {
const now = Date.now();
const taskId = await ctx.db.insert('tasks', {
...args,
status: 'todo',
createdAt: now,
updatedAt: now,
});
return taskId;
},
});
// Update task status with optimistic update support
export const updateStatus = mutation({
args: {
taskId: v.id('tasks'),
status: v.union(
v.literal('todo'),
v.literal('in_progress'),
v.literal('done'),
),
},
handler: async (ctx, args) => {
const task = await ctx.db.get(args.taskId);
if (!task) throw new Error('Task not found');
await ctx.db.patch(args.taskId, {
status: args.status,
updatedAt: Date.now(),
});
},
});
// Full-text search
export const search = query({
args: {
projectId: v.id('projects'),
searchTerm: v.string(),
},
handler: async (ctx, args) => {
return ctx.db
.query('tasks')
.withSearchIndex('search_title', (q) =>
q.search('title', args.searchTerm).eq('projectId', args.projectId),
)
.take(20);
},
});
Step 4: Use in React with Real-Time Updates
// src/components/TaskBoard.tsx
import { useQuery, useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { Id } from "../../convex/_generated/dataModel";
function TaskBoard({ projectId }: { projectId: Id<"projects"> }) {
const tasks = useQuery(api.tasks.list, { projectId });
const updateStatus = useMutation(api.tasks.updateStatus);
// This is REAL-TIME! Open two browser windows and watch them sync
const handleDragEnd = async (taskId: Id<"tasks">, newStatus: string) => {
await updateStatus({
taskId,
status: newStatus as "todo" | "in_progress" | "done",
});
};
if (tasks === undefined) return <LoadingSkeleton />;
return (
<div className="grid grid-cols-3 gap-4">
{["todo", "in_progress", "done"].map((status) => (
<TaskColumn
key={status}
status={status}
tasks={tasks.filter((t) => t.status === status)}
onDrop={(taskId) => handleDragEnd(taskId, status)}
/>
))}
</div>
);
}
Open two browser windows. Drag a task in one window. Watch it move instantly in the other. No WebSocket code. No polling. No optimistic update boilerplate.
Solving Real Engineering Problems
Now let’s tackle the challenges you’ll actually face in production.
Problem 1: Authentication
Convex has first-class integrations with Clerk, Auth0, and custom OIDC providers.
With Clerk (Recommended):
// convex/auth.config.ts
export default {
providers: [
{
domain: process.env.CLERK_JWT_ISSUER_DOMAIN,
applicationID: 'convex',
},
],
};
// convex/users.ts
import { mutation, query, QueryCtx, MutationCtx } from './_generated/server';
// Helper to get current user - use this everywhere
export async function getCurrentUser(ctx: QueryCtx | MutationCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
return ctx.db
.query('users')
.withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
.unique();
}
// Get or create user on first login
export const getOrCreate = mutation({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error('Not authenticated');
const existing = await ctx.db
.query('users')
.withIndex('by_clerk_id', (q) => q.eq('clerkId', identity.subject))
.unique();
if (existing) return existing._id;
return ctx.db.insert('users', {
clerkId: identity.subject,
email: identity.email!,
name: identity.name ?? 'Anonymous',
avatarUrl: identity.pictureUrl,
});
},
});
// Frontend setup
import { ClerkProvider, useAuth } from "@clerk/clerk-react";
import { ConvexProviderWithClerk } from "convex/react-clerk";
import { ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
function App() {
return (
<ClerkProvider publishableKey={import.meta.env.VITE_CLERK_PUBLISHABLE_KEY}>
<ConvexProviderWithClerk client={convex} useAuth={useAuth}>
<YourApp />
</ConvexProviderWithClerk>
</ClerkProvider>
);
}
Problem 2: Multi-Tenancy
Here’s a production-ready multi-tenancy pattern. Get this wrong and you’ll be explaining to Customer A why they can see Customer B’s data.
// convex/schema.ts - Add organization tables
export default defineSchema({
organizations: defineTable({
name: v.string(),
slug: v.string(),
ownerId: v.id('users'),
plan: v.union(v.literal('free'), v.literal('pro'), v.literal('enterprise')),
createdAt: v.number(),
})
.index('by_slug', ['slug'])
.index('by_owner', ['ownerId']),
memberships: defineTable({
userId: v.id('users'),
organizationId: v.id('organizations'),
role: v.union(v.literal('owner'), v.literal('admin'), v.literal('member')),
joinedAt: v.number(),
})
.index('by_user', ['userId'])
.index('by_organization', ['organizationId'])
.index('by_user_org', ['userId', 'organizationId']),
// ALL tenant data includes organizationId
projects: defineTable({
organizationId: v.id('organizations'),
name: v.string(),
// ... other fields
}).index('by_organization', ['organizationId']),
});
// convex/lib/multitenancy.ts - Access control helper
import { QueryCtx, MutationCtx } from '../_generated/server';
import { Id } from '../_generated/dataModel';
import { getCurrentUser } from '../users';
export async function requireOrgAccess(
ctx: QueryCtx | MutationCtx,
organizationId: Id<'organizations'>,
requiredRole?: 'owner' | 'admin' | 'member',
) {
const user = await getCurrentUser(ctx);
if (!user) throw new Error('Not authenticated');
const membership = await ctx.db
.query('memberships')
.withIndex('by_user_org', (q) =>
q.eq('userId', user._id).eq('organizationId', organizationId),
)
.unique();
if (!membership) {
throw new Error('Access denied: Not a member of this organization');
}
if (requiredRole) {
const roleHierarchy = { owner: 3, admin: 2, member: 1 };
if (roleHierarchy[membership.role] < roleHierarchy[requiredRole]) {
throw new Error(`Access denied: Requires ${requiredRole} role`);
}
}
return { user, membership };
}
// Usage in any function
export const createProject = mutation({
args: {
organizationId: v.id('organizations'),
name: v.string(),
},
handler: async (ctx, args) => {
// This throws if user doesn't have admin access
await requireOrgAccess(ctx, args.organizationId, 'admin');
return ctx.db.insert('projects', {
organizationId: args.organizationId,
name: args.name,
createdAt: Date.now(),
});
},
});
Problem 3: Rate Limiting
Use the official Convex Rate Limiter component:
npm install @convex-dev/rate-limiter
// convex/rateLimiter.ts
import { RateLimiter } from '@convex-dev/rate-limiter';
import { components } from './_generated/api';
export const rateLimiter = new RateLimiter(components.rateLimiter, {
// Global limits
freeTrialSignup: { kind: 'fixed window', rate: 10, period: 60000 },
// Per-user limits
sendMessage: { kind: 'token bucket', rate: 10, period: 1000, capacity: 30 },
// API rate limiting
apiCalls: { kind: 'fixed window', rate: 100, period: 60000 },
// AI/LLM token limiting
aiTokens: { kind: 'token bucket', rate: 1000, period: 60000, capacity: 5000 },
});
// convex/messages.ts
import { rateLimiter } from './rateLimiter';
export const send = mutation({
args: { content: v.string(), channelId: v.id('channels') },
handler: async (ctx, args) => {
const user = await getCurrentUser(ctx);
if (!user) throw new Error('Not authenticated');
// Check rate limit - throws if exceeded
await rateLimiter.limit(ctx, 'sendMessage', {
key: user._id,
throws: true,
});
return ctx.db.insert('messages', {
content: args.content,
channelId: args.channelId,
authorId: user._id,
createdAt: Date.now(),
});
},
});
Problem 4: File Uploads with Access Control
// convex/files.ts
export const generateUploadUrl = mutation({
args: { organizationId: v.id('organizations') },
handler: async (ctx, args) => {
await requireOrgAccess(ctx, args.organizationId);
return ctx.storage.generateUploadUrl();
},
});
export const saveFile = mutation({
args: {
storageId: v.id('_storage'),
fileName: v.string(),
organizationId: v.id('organizations'),
},
handler: async (ctx, args) => {
await requireOrgAccess(ctx, args.organizationId);
return ctx.db.insert('files', {
storageId: args.storageId,
fileName: args.fileName,
organizationId: args.organizationId,
uploadedAt: Date.now(),
});
},
});
export const getFileUrl = query({
args: { fileId: v.id('files') },
handler: async (ctx, args) => {
const file = await ctx.db.get(args.fileId);
if (!file) return null;
await requireOrgAccess(ctx, file.organizationId);
return ctx.storage.getUrl(file.storageId);
},
});
// Frontend upload function
async function uploadFile(file: File, organizationId: Id<'organizations'>) {
const uploadUrl = await generateUploadUrl({ organizationId });
const response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type },
body: file,
});
const { storageId } = await response.json();
await saveFile({ storageId, fileName: file.name, organizationId });
}
Problem 5: Background Jobs & Scheduling
// convex/crons.ts
import { cronJobs } from 'convex/server';
import { internal } from './_generated/api';
const crons = cronJobs();
// Daily cleanup
crons.daily(
'cleanup expired sessions',
{ hourUTC: 3, minuteUTC: 0 },
internal.maintenance.cleanupSessions,
);
// Weekly digest
crons.weekly(
'send weekly digest',
{ dayOfWeek: 'monday', hourUTC: 9, minuteUTC: 0 },
internal.emails.sendWeeklyDigest,
);
// Every minute webhook processing
crons.interval(
'process webhooks',
{ minutes: 1 },
internal.webhooks.processPending,
);
export default crons;
// Scheduled tasks from mutations
export const createTask = mutation({
args: { /* ... */ dueDate: v.optional(v.number()) },
handler: async (ctx, args) => {
const taskId = await ctx.db.insert('tasks', {
/* ... */
});
// Schedule reminder 24 hours before due date
if (args.dueDate) {
const reminderTime = args.dueDate - 24 * 60 * 60 * 1000;
if (reminderTime > Date.now()) {
await ctx.scheduler.runAt(
reminderTime,
internal.notifications.sendTaskReminder,
{ taskId },
);
}
}
return taskId;
},
});
Problem 6: Full-Text Search
// Already in schema with searchIndex
export const searchTasks = query({
args: {
organizationId: v.id('organizations'),
query: v.string(),
status: v.optional(v.string()),
},
handler: async (ctx, args) => {
await requireOrgAccess(ctx, args.organizationId);
return ctx.db
.query('tasks')
.withSearchIndex('search_title', (q) => {
let search = q
.search('title', args.query)
.eq('organizationId', args.organizationId);
if (args.status) {
search = search.eq('status', args.status);
}
return search;
})
.take(25);
},
});
Problem 7: Pagination
// convex/tasks.ts
import { paginationOptsValidator } from 'convex/server';
export const listPaginated = query({
args: {
projectId: v.id('projects'),
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, args) => {
return ctx.db
.query('tasks')
.withIndex('by_project', (q) => q.eq('projectId', args.projectId))
.order('desc')
.paginate(args.paginationOpts);
},
});
// Frontend with infinite scroll
import { usePaginatedQuery } from "convex/react";
function TaskList({ projectId }) {
const { results, status, loadMore } = usePaginatedQuery(
api.tasks.listPaginated,
{ projectId },
{ initialNumItems: 20 }
);
return (
<div>
{results.map((task) => <TaskCard key={task._id} task={task} />)}
{status === "CanLoadMore" && (
<button onClick={() => loadMore(20)}>Load More</button>
)}
</div>
);
}
Problem 8: Webhooks
// convex/http.ts
import { httpRouter } from 'convex/server';
import { httpAction } from './_generated/server';
import { internal } from './_generated/api';
const http = httpRouter();
http.route({
path: '/webhooks/stripe',
method: 'POST',
handler: httpAction(async (ctx, request) => {
const signature = request.headers.get('stripe-signature');
const body = await request.text();
// Verify signature
const isValid = await ctx.runAction(internal.stripe.verifyWebhook, {
signature: signature!,
body,
});
if (!isValid) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(body);
// Process asynchronously
await ctx.runMutation(internal.webhooks.record, {
provider: 'stripe',
eventType: event.type,
payload: event,
});
return new Response('OK', { status: 200 });
}),
});
export default http;
Problem 9: Optimistic Updates
// Frontend with instant UI feedback
const updateStatus = useMutation(api.tasks.updateStatus).withOptimisticUpdate(
(localStore, args) => {
const currentValue = localStore.getQuery(api.tasks.list, {
projectId: currentProjectId,
});
if (currentValue) {
localStore.setQuery(
api.tasks.list,
{ projectId: currentProjectId },
currentValue.map((task) =>
task._id === args.taskId
? { ...task, status: args.status, updatedAt: Date.now() }
: task,
),
);
}
},
);
Problem 10: External API Integration (Actions)
// convex/ai.ts
'use node';
import { internalAction } from './_generated/server';
import { v } from 'convex/values';
import OpenAI from 'openai';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export const generateSummary = internalAction({
args: { content: v.string() },
handler: async (ctx, args) => {
const completion = await openai.chat.completions.create({
model: 'gpt-5-nano',
messages: [
{
role: 'system',
content: 'Summarize the following in 2-3 sentences.',
},
{ role: 'user', content: args.content },
],
max_tokens: 150,
});
return completion.choices[0].message.content;
},
});
Common Gotchas & Best Practices
1. Always Use Indexes
// ❌ BAD - Full table scan
const tasks = await ctx.db.query('tasks').collect();
const filtered = tasks.filter((t) => t.projectId === projectId);
// ✅ GOOD - Uses index
const tasks = await ctx.db
.query('tasks')
.withIndex('by_project', (q) => q.eq('projectId', projectId))
.collect();
2. Actions Can’t Access Database Directly
Actions run in a Node.js environment (for external API calls), not the Convex runtime.
// ❌ BAD - ctx.db doesn't exist in actions
export const myAction = action({
handler: async (ctx) => {
await ctx.db.insert('logs', { message: 'hi' }); // Error!
},
});
// ✅ GOOD - Use runMutation
export const myAction = action({
handler: async (ctx) => {
await ctx.runMutation(internal.logs.create, { message: 'hi' });
},
});
3. Use v.optional() for New Fields
// When adding fields to existing tables
tasks: defineTable({
title: v.string(),
newField: v.optional(v.string()), // Safe for existing docs
});
4. Handle Missing Documents
// ❌ BAD - Assumes document exists
const task = await ctx.db.get(args.taskId);
await ctx.db.patch(task._id, { status: 'done' }); // Crash if null!
// ✅ GOOD - Check existence
const task = await ctx.db.get(args.taskId);
if (!task) throw new Error('Task not found');
await ctx.db.patch(task._id, { status: 'done' });
5. Use Internal Functions for Sensitive Logic
// ✅ internalMutation can't be called from client
export const processPayment = internalMutation({
args: { userId: v.id('users'), amount: v.number() },
handler: async (ctx, args) => {
// Sensitive payment logic
},
});
The Convex Component Ecosystem: Your Secret Weapon
Here’s something that sets Convex apart from every other backend: Components.
Components are drop-in, sandboxed mini-backends that solve common problems with zero configuration drama. They have their own isolated database tables, can’t access your data unless you explicitly allow it, and install with a single npm command.
Think of them as “if someone already built that feature perfectly, and you could just npm install it."
How Components Work
# 1. Install the component
npm install @convex-dev/rate-limiter
# 2. Add to convex.config.ts
// convex.config.ts
import { defineApp } from 'convex/server';
import rateLimiter from '@convex-dev/rate-limiter/convex.config';
const app = defineApp();
app.use(rateLimiter);
export default app;
// 3. Use it
import { RateLimiter } from '@convex-dev/rate-limiter';
import { components } from './_generated/api';
export const rateLimiter = new RateLimiter(components.rateLimiter, {
sendMessage: { kind: 'token bucket', rate: 10, period: 1000, capacity: 30 },
});
That’s it. Production-ready rate limiting in 10 lines.
Essential Components by Category
Backend Infrastructure
[embed]
Integrations
[embed]
AI & Real-time
[embed]
Browse all components at convex.dev/components — each package is available on npm under the @convex-dev/* namespace.
Real Example: Durable Workflows
Building an onboarding flow that sends emails over several days? Here’s how trivial it becomes:
// convex/onboarding.ts
import { WorkflowManager } from '@convex-dev/workflow';
import { components, internal } from './_generated/api';
const workflow = new WorkflowManager(components.workflow);
export const onboardingWorkflow = workflow.define({
args: { userId: v.id('users') },
handler: async (ctx, args) => {
// Step 1: Send welcome email immediately
await ctx.runAction(internal.emails.sendWelcome, { userId: args.userId });
// Step 2: Wait 24 hours, then send tips email
await ctx.sleep(24 * 60 * 60 * 1000);
await ctx.runAction(internal.emails.sendTips, { userId: args.userId })
// Step 3: Wait 3 more days, then check engagement
await ctx.sleep(3 * 24 * 60 * 60 * 1000);
const user = await ctx.runQuery(internal.users.get, {
userId: args.userId,
});
if (!user.hasCompletedSetup) {
await ctx.runAction(internal.emails.sendNudge, { userId: args.userId });
}
// This workflow survives server restarts, deployments, everything
},
});
This workflow runs over 4+ days, survives deployments, and tracks progress automatically. Good luck doing that with Bull and Redis.
Real Example: Leaderboard with O(log n) Rankings
import { TableAggregate } from '@convex-dev/aggregate';
import { components } from './_generated/api';
const leaderboard = new TableAggregate(components.aggregate, {
sortKey: (doc) => doc.score,
});
// Get user's rank out of millions — in milliseconds
export const getRank = query({
args: { odId: v.id('scores') },
handler: async (ctx, args) => {
const doc = await ctx.db.get(args.userId);
return await leaderboard.indexOf(ctx, doc); // O(log n), not O(n)
},
});
Why Components Matter
Before components:
- “I need rate limiting” → Research libraries, set up Redis, handle edge cases, debug for a week
- “I need user presence” → WebSocket server, heartbeats, cleanup logic, 500 lines of code
- “I need to migrate data” → Pray nothing breaks, write one-off scripts
After components:
npm install @convex-dev/rate-limiter
npm install @convex-dev/presence
npm install @convex-dev/migrations
Done.
Official Templates
Get started faster with production-ready templates:
Official Templates
React Starter Kit
- Features: Clerk auth, Stripe, AI chat
- Command:
npm create convex@latest -t react-starter-kit
v1 Starter
- Features: Full SaaS, based on Midday
- Command:
npx create-convex@latest -t get-convex/v1
Iris SaaS Kit
- Features: Multi-tenant, super-admin
- Get it: convex.dev/templates
TanStack SaaS
- Features: TanStack + Stripe + Resend
- Get it: convex.dev/templates
Deploying to Production
This section is almost embarrassingly short. I’d pad it out, but that would be dishonest.
Step 1: Deploy Your Backend
npx convex deploy
That’s it. Your functions, schema, and indexes are now live on Convex’s infrastructure. There’s no:
- Docker containers to configure
- Kubernetes clusters to manage
- Database migrations to run manually
- Load balancers to set up
- Auto-scaling rules to define
- SSL certificates to provision
- Cold start optimization to worry about
Just… npx convex deploy. You get a URL. It works.
Step 2: Set Environment Variables
Go to dashboard.convex.dev → Your Project → Settings → Environment Variables
Add your secrets:
CLERK_JWT_ISSUER_DOMAINOPENAI_API_KEYSTRIPE_SECRET_KEY- Whatever else your actions need
Step 3: Deploy Your Frontend
Your frontend can go anywhere that serves static files or runs Node.js:
Vercel (zero-config):
vercel --prod
Netlify:
netlify deploy --prod
Cloudflare Pages:
wrangler pages deploy dist
Railway/Render/Fly.io: Just connect your repo. Done.
Set VITE_CONVEX_URL (or NEXT_PUBLIC_CONVEX_URL, etc.) to your production Convex URL, which looks like [https://your-project-123.convex.cloud.](https://your-project-123.convex.cloud.)
Step 4: There Is No Step 4
Your backend automatically:
- Scales to handle traffic spikes
- Maintains hot replicas
- Handles WebSocket connections for real-time
- Caches queries intelligently
- Runs scheduled jobs reliably
- Stores files with CDN delivery
I’ve deployed production apps that handle thousands of concurrent real-time connections. The deployment process was still just npx convex deploy.
Self-Hosting: You Have Options
As of February 2025, Convex is fully self-hostable. The backend is open-source under the FSL Apache 2.0 license, and you can run it with PostgreSQL, MySQL, or SQLite.
Option 1: Convex Cloud (Recommended for Most Teams)
The managed cloud is still the easiest path. You get:
- Zero infrastructure management
- Automatic scaling and replication
- Built-in monitoring and observability
- Generous free tier for development
For most teams, this is the right choice. Focus on your product, not your database cluster.
Option 2: Self-Hosted Convex
If you need data sovereignty, air-gapped environments, or cost optimization at scale:
# Docker deployment (recommended)
docker run -d \
-e DATABASE_URL=postgres://user:pass@host:5432/convex \
-p 3210:3210 \
ghcr.io/get-convex/convex-backend:latest
Self-hosted Convex includes:
- The full dashboard and CLI integration
- Support for PostgreSQL, MySQL, and SQLite backends
- All core features (real-time sync, transactions, scheduling)
- Works with Fly.io, Coolify, or any Docker host
Resources for self-hosting:
- Self-Hosting Guide
- GitHub: convex-backend
- Discord: #self-hosted channel for community support
When to Choose Each:
[embed]
Resources
- Documentation: docs.convex.dev
- Patterns & Tutorials: stack.convex.dev
- Templates: convex.dev/templates
- Components: convex.dev/components
- Discord: convex.dev/community
- GitHub Examples: github.com/get-convex/convex-demos
- convex-helpers: github.com/get-convex/convex-helpers — Essential utilities
Conclusion
Convex eliminates entire categories of complexity. Real-time sync, type safety, transactions, authentication, multi-tenancy, rate limiting, file storage, background jobs, and search — all handled with remarkably little code.
The patterns I’ve shown represent real production requirements. Each one would typically require significant infrastructure and code. With Convex, they’re all first-class features.
Stop wrestling with infrastructure. Start building your product.
What to Read Next
If you found this useful:
- **Convex + TanStack Start** — Combine Convex with type-safe routing
- **Convex Auth Guide** — Deep dive into authentication patterns
- **Building a SaaS with Convex** — Production-ready starter template
Questions? Drop them in the comments. I read every one.
Now go build something.
메타데이터
- post_id
- 2fac0e418ef2
- slug
- convex-backend-without-the-backlog-2fac0e418ef2
- url
- https://medium.com/@ruintheextinct/convex-backend-without-the-backlog-2fac0e418ef2
- canonical_url
- https://medium.com/@ruintheextinct/convex-backend-without-the-backlog-2fac0e418ef2
- author_url
- https://medium.com/@ruintheextinct
- status
- ok
- fetched_at
- 2026-07-15 04:21:51