← Back to list

Convex: Backend Without the Backlog

I replaced Postgres, Redis, WebSockets, and three microservices with 200 lines of TypeScript. Here’s exactly how.

RUiNtheExtinct · 2026-01-23 11:34 · 0 claps · 13.7 min read
#convex #backend #nodejs #typescript #scalability
Open on Medium ↗
Wiki topics: 🌐 · Web Development 🔒 · Cybersecurity

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:

  1. Database — PostgreSQL, MySQL, MongoDB
  2. ORM/Query Builder — Prisma, Drizzle, TypeORM
  3. API Layer — REST endpoints or GraphQL resolvers
  4. Real-time — WebSockets, Socket.io, Pusher
  5. Caching — Redis, Memcached
  6. Background Jobs — Bull, Agenda, custom workers
  7. File Storage — S3, Cloudinary
  8. 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

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

TanStack SaaS

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_DOMAIN
  • OPENAI_API_KEY
  • STRIPE_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:

When to Choose Each:

[embed]

Resources

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:

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