Loading...
Loading...
Weekly AI insights —
Real strategies, no fluff. Unsubscribe anytime.
Written by Gareth Simono, Founder and CEO of Agentik {OS}. Full-stack developer and AI architect with years of experience shipping production applications across SaaS, mobile, and enterprise platforms. Gareth orchestrates 267 specialized AI agents to deliver production software 10x faster than traditional development teams.
Founder & CEO, Agentik {OS}
Convex eliminates WebSocket plumbing, cache invalidation, and consistency headaches. Every query is a live subscription. Here's what that means in practice.

I've rebuilt the same real-time feature five times across my career. WebSocket connection management. Reconnection logic. Message queuing during disconnects. Optimistic updates. Cache invalidation when server state changes. Conflict resolution when two clients update simultaneously.
Every implementation taught me something. Every implementation was also three weeks of work that had nothing to do with the feature I was actually building.
Convex eliminates all of it. Not abstracts it. Eliminates it. You write queries. They're automatically live. You write mutations. They're automatically consistent. The infrastructure problem is solved.
This guide covers everything you need to build production applications with Convex: data modeling, real-time queries, mutations, authentication integration, AI integration, and the edge cases that catch beginners.
Convex's core model is different from traditional databases in one fundamental way: queries are not point-in-time reads. They're subscriptions.
When a client executes a Convex query, it doesn't get data back and disconnect. It establishes a reactive connection. When any data that query depends on changes, Convex automatically re-evaluates the query and pushes the updated result to the client.
You don't write WebSocket code. You don't write cache invalidation code. You write a query function. The real-time behavior is automatic.
// convex/messages.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
// This query is automatically live. When any message in this channel changes,
// every client running this query receives the update automatically.
export const getMessages = query({
args: { channelId: v.id("channels") },
handler: async (ctx, { channelId }) => {
return await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.order("desc")
.take(50);
},
});
export const sendMessage = mutation({
args: {
channelId: v.id("channels"),
content: v.string(),
},
handler: async (ctx, { channelId, content }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
await ctx.db.insert("messages", {
channelId,
content,
userId: identity.subject,
timestamp: Date.now(),
});
},
});// app/components/MessageList.tsx - Client Component
"use client";
import { useQuery, useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import { Id } from "@/convex/_generated/dataModel";
export function MessageList({ channelId }: { channelId: Id<"channels"> }) {
// This automatically updates when any message in this channel changes.
// No useEffect, no polling, no WebSocket code.
const messages = useQuery(api.messages.getMessages, { channelId });
const sendMessage = useMutation(api.messages.sendMessage);
return (
<div>
{messages?.map((msg) => (
<div key={msg._id}>{msg.content}</div>
))}
</div>
);
}The client subscribes to the query. When sendMessage runs, Convex updates the database and pushes the change to every subscribed client. The message list updates everywhere, instantly, without polling or manual state management.
Convex schemas are TypeScript. The types are inferred throughout your application automatically.
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
clerkId: v.string(),
email: v.string(),
name: v.string(),
imageUrl: v.optional(v.string()),
createdAt: v.number(),
}).index("by_clerk_id", ["clerkId"]),
channels: defineTable({
name: v.string(),
description: v.optional(v.string()),
isPrivate: v.boolean(),
ownerId: v.id("users"),
memberCount: v.number(),
lastActivityAt: v.number(),
}).index("by_last_activity", ["lastActivityAt"]),
messages: defineTable({
channelId: v.id("channels"),
userId: v.id("users"),
content: v.string(),
timestamp: v.number(),
editedAt: v.optional(v.number()),
// For AI-generated messages
isAiGenerated: v.optional(v.boolean()),
aiModel: v.optional(v.string()),
})
.index("by_channel", ["channelId", "timestamp"])
.index("by_user", ["userId", "timestamp"]),
aiConversations: defineTable({
userId: v.id("users"),
title: v.string(),
lastMessageAt: v.number(),
}).index("by_user", ["userId", "lastMessageAt"]),
});The schema generates TypeScript types for your entire data model. v.id("channels") generates a branded string type that the TypeScript compiler uses to prevent you from accidentally using a user ID where a channel ID is expected.
Define your indexes deliberately. Convex requires explicit indexes for any filtered query. This forces you to think about your query patterns upfront, which leads to better data modeling.
Convex and Clerk integrate cleanly. Clerk handles identity. Convex uses Clerk's JWT to identify the user in every query and mutation.
// convex/auth.config.ts
export default {
providers: [
{
domain: process.env.CLERK_JWT_ISSUER_DOMAIN,
applicationID: "convex",
},
],
};// convex/users.ts
import { mutation, query } from "./_generated/server";
// Called on every sign-in to upsert the user record
export const upsertUser = mutation({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
const existing = await ctx.db
.query("users")
.withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject))
.first();
if (existing) {
await ctx.db.patch(existing._id, {
name: identity.name ?? existing.name,
imageUrl: identity.pictureUrl ?? existing.imageUrl,
});
return existing._id;
}
return await ctx.db.insert("users", {
clerkId: identity.subject,
email: identity.email!,
name: identity.name ?? identity.email!,
imageUrl: identity.pictureUrl,
createdAt: Date.now(),
});
},
});
// Helper to get the current user record with type safety
export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
return await ctx.db
.query("users")
.withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject))
.first();
},
});Every mutation can call ctx.auth.getUserIdentity() to get the authenticated user. This pattern makes authentication checks consistent and non-optional.
Convex actions can call external APIs, including AI models. The pattern for AI-powered features:
// convex/ai.ts
import { action, mutation } from "./_generated/server";
import { v } from "convex/values";
import Anthropic from "@anthropic-ai/sdk";
import { api } from "./_generated/api";
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
export const generateAIResponse = action({
args: {
conversationId: v.id("aiConversations"),
userMessage: v.string(),
},
handler: async (ctx, { conversationId, userMessage }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
// Create placeholder message for streaming
const messageId = await ctx.runMutation(api.messages.createAIMessagePlaceholder, {
conversationId,
userId: identity.subject,
});
let fullContent = "";
// Stream the AI response
const stream = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 2000,
stream: true,
messages: [{ role: "user", content: userMessage }],
});
// Update the message as content streams in
for await (const chunk of stream) {
if (
chunk.type === "content_block_delta" &&
chunk.delta.type === "text_delta"
) {
fullContent += chunk.delta.text;
// Update every 100ms to avoid mutation rate limits
await ctx.runMutation(api.messages.updateStreamingMessage, {
messageId,
content: fullContent,
isComplete: false,
});
}
}
// Mark as complete
await ctx.runMutation(api.messages.updateStreamingMessage, {
messageId,
content: fullContent,
isComplete: true,
});
return messageId;
},
});Clients subscribed to the conversation query see each partial update as it arrives. The AI response appears to stream directly into the UI, even though it's going through the database.
The database-as-streaming-layer pattern is unique to Convex. With traditional architectures, you'd need a separate WebSocket layer for streaming AI responses. Convex's reactive queries make the database serve that role.
Convex mutations are fast, but not instantaneous. When a user sends a message, there's a 50-200ms round trip to the server. That latency is noticeable in interactive applications.
Optimistic updates show the expected result immediately, before the server confirms:
// Optimistic mutation that shows the message immediately
const sendMessage = useMutation(api.messages.sendMessage).withOptimisticUpdate(
(localStore, args) => {
const { channelId, content } = args;
const messages = localStore.getQuery(api.messages.getMessages, { channelId });
if (messages !== undefined) {
// Add the message optimistically before server confirms
const optimisticMessage = {
_id: crypto.randomUUID() as any,
_creationTime: Date.now(),
channelId,
content,
userId: currentUserId, // From your auth context
timestamp: Date.now(),
};
localStore.setQuery(
api.messages.getMessages,
{ channelId },
[...messages, optimisticMessage]
);
}
}
);The message appears instantly. If the server mutation fails, Convex automatically reverts the optimistic update. If it succeeds, the server-confirmed version replaces the optimistic one.
Convex supports scheduled functions and cron jobs that run server-side. For AI applications, this enables:
// convex/crons.ts
import { cronJobs } from "convex/server";
import { api } from "./_generated/api";
const crons = cronJobs();
// Generate daily AI summaries at 6 AM UTC
crons.daily(
"generate-daily-summaries",
{ hourUTC: 6, minuteUTC: 0 },
api.ai.generateDailySummaries
);
export default crons;// convex/ai.ts - The cron handler
export const generateDailySummaries = action({
args: {},
handler: async (ctx) => {
// Fetch all active users
const users = await ctx.runQuery(api.users.getActiveUsers, {});
// Generate personalized AI summaries for each user
for (const user of users) {
await ctx.scheduler.runAfter(0, api.ai.generateUserSummary, {
userId: user._id,
});
}
},
});A few things I've learned the hard way about Convex in production.
Index everything you query. Convex will let you run unindexed queries in development but throw errors in production when tables grow large. Define your indexes before you discover you need them.
Rate limit mutations client-side. Convex has limits on mutation rate. For real-time streaming AI responses, debounce your update mutations rather than calling on every character.
Use ctx.db.patch for partial updates. Replacing an entire document with ctx.db.replace triggers more reactive query re-evaluations than patching only changed fields. Patch for surgical updates.
Plan your reactive query boundaries. A query that depends on ten tables will re-execute whenever any of those tables change. Keep query dependencies tight to minimize unnecessary re-evaluations.
The Next.js 16 integration with Convex is where real-time AI applications come together. Server Components for the initial render, Convex for real-time updates, Clerk for auth. This stack handles most AI application requirements without requiring custom infrastructure.
Q: What is Convex and how does it work?
Convex is a real-time backend platform combining database, serverless functions, and real-time subscriptions. Queries automatically push updates to clients when data changes. Functions run as serverless TypeScript with automatic caching, transactions, and real-time sync.
Q: Why choose Convex over Supabase or Firebase?
Convex excels for real-time apps: collaborative tools, dashboards, chat. Advantages are automatic reactive queries (no manual WebSocket setup), ACID transactions by default, TypeScript-first with full type safety, and automatic scaling. Choose Supabase for SQL-heavy apps needing PostGIS or complex joins.
Q: How does Convex integrate with AI agents for development?
Convex works exceptionally well with AI agents: TypeScript-first approach provides type safety guiding agent output, convention-based structure is easy to navigate, and built-in features reduce boilerplate. AI agents can build a complete Convex backend in hours.
Full-stack developer and AI architect with years of experience shipping production applications across SaaS, mobile, and enterprise. Gareth built Agentik {OS} to prove that one person with the right AI system can outperform an entire traditional development team. He has personally architected and shipped 7+ production applications using AI-first workflows.

Next.js 16 + AI: Build Intelligent Apps Fast
Next.js 16 solves AI's hardest problems: secret exposure, blocking UIs, and scaling costs. Here's the architecture that actually works in production.

Clerk Auth: Skip 18 Months of Authentication Pain
Authentication grows from JWT to MFA to SAML to org management. Clerk handles the whole journey so you can build what actually matters.

Real-Time Apps with AI Agents and Convex
Your users refresh to see new data. That's a 2015 architecture. Real-time with Convex and AI agents makes reactivity the default, not a bolt-on.
Stop reading about AI and start building with it. Book a free discovery call and see how AI agents can accelerate your business.