Instruction file imported from marcoacciarri/next-convex-ai-saas-boilerplate (
.github/instructions/convex.instructions.md). Copyright stays with the author.
Convex Development Guidelines for GitHub Copilot
This document provides comprehensive guidelines and best practices for building Convex applications. Follow these patterns when working with Convex functions, schemas, and database operations.
Core Function Syntax
Modern Function Definition Pattern
Important:
Always import Convex function decorators (query, mutation, action, etc.) from your project's generated server module, not from the Convex package directly.
Correct import:
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
export const exampleFunction = query({
args: { name: v.string() },
returns: v.string(),
handler: async (ctx, args) => {
// Function implementation
return `Hello, ${args.name}`;
},
});
Do NOT use:
import { query, mutation } from "convex/server"; // ❌ Incorrect
HTTP Endpoints
Define HTTP endpoints in convex/http.ts with the httpAction decorator:
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
http.route({
path: "/api/endpoint",
method: "POST",
handler: httpAction(async (ctx, req) => {
const body = await req.bytes();
return new Response(body, { status: 200 });
}),
});
Validation Patterns
Basic Validators
Use these validation patterns for common data types:
// Array validator
args: {
items: v.array(v.union(v.string(), v.number())),
}
// Discriminated union validator
v.union(
v.object({
kind: v.literal("error"),
errorMessage: v.string(),
}),
v.object({
kind: v.literal("success"),
value: v.number(),
}),
)
// Null return validator
returns: v.null(),
Complete Validator Reference
| Convex Type | TypeScript Type | Validator | Notes |
|---|---|---|---|
| Id | string | v.id(tableName) |
Document IDs |
| Null | null | v.null() |
Use instead of undefined |
| Int64 | bigint | v.int64() |
64-bit integers |
| Float64 | number | v.number() |
IEEE-754 doubles |
| Boolean | boolean | v.boolean() |
Boolean values |
| String | string | v.string() |
UTF-8 strings |
| Bytes | ArrayBuffer | v.bytes() |
Binary data |
| Array | Array | v.array(validator) |
Max 8192 items |
| Object | Object | v.object({...}) |
Plain objects only |
| Record | Record | v.record(keys, values) |
Dynamic keys |
Function Registration and Access Control
Public Functions
Use for client-accessible APIs:
export const publicFunction = query({
args: {},
returns: v.string(),
handler: async (ctx, args) => {
return "Public data";
},
});
Internal Functions
Use for server-only operations:
export const internalFunction = internalQuery({
args: {},
returns: v.string(),
handler: async (ctx, args) => {
return "Internal data";
},
});
Function Calling Patterns
// Call functions using proper context methods
const queryResult = await ctx.runQuery(api.module.functionName, args);
const mutationResult = await ctx.runMutation(api.module.functionName, args);
const actionResult = await ctx.runAction(api.module.functionName, args);
// For same-file calls, add type annotation
const result: string = await ctx.runQuery(api.example.f, { name: "Bob" });
Database Schema Design
Schema Definition
Always define schemas in convex/schema.ts:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
name: v.string(),
email: v.string(),
role: v.union(v.literal("admin"), v.literal("user")),
}).index("by_email", ["email"]),
messages: defineTable({
userId: v.id("users"),
content: v.string(),
channelId: v.string(),
}).index("by_user_and_channel", ["userId", "channelId"]),
});
Index Naming Convention
- Include all index fields in the name
- Use format:
by_field1_and_field2 - Example:
.index("by_user_and_channel", ["userId", "channelId"])
System Fields
All documents automatically include:
_id:v.id(tableName)- Unique document ID_creationTime:v.number()- Creation timestamp
Query Patterns
Basic Query Operations
// Use indexes instead of filters
const messages = await ctx.db
.query("messages")
.withIndex("by_user", (q) => q.eq("userId", userId))
.order("desc")
.take(10);
// Get unique document
const user = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", email))
.unique();
// Async iteration
for await (const message of ctx.db.query("messages")) {
// Process each message
}
Full-Text Search
const results = await ctx.db
.query("messages")
.withSearchIndex("search_content", (q) =>
q.search("content", searchTerm).eq("channelId", channelId),
)
.take(10);
Pagination
import { paginationOptsValidator } from "convex/server";
export const paginatedQuery = query({
args: {
paginationOpts: paginationOptsValidator,
channelId: v.string(),
},
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.filter((q) => q.eq(q.field("channelId"), args.channelId))
.order("desc")
.paginate(args.paginationOpts);
},
});
Mutation Operations
Document Modification
// Create new document
const id = await ctx.db.insert("users", { name, email });
// Update existing document (partial)
await ctx.db.patch(documentId, { name: newName });
// Replace entire document
await ctx.db.replace(documentId, { name, email, role });
// Delete document
await ctx.db.delete(documentId);
Actions and External APIs
Action Structure
"use node"; // Add for Node.js modules
import { action } from "./_generated/server";
export const externalApiCall = action({
args: { data: v.string() },
returns: v.object({ result: v.string() }),
handler: async (ctx, args) => {
// External API calls here
const response = await fetch("https://api.example.com/data");
return { result: await response.text() };
},
});
Key Action Guidelines
- Never use
ctx.dbin actions - Add
"use node";for Node.js built-ins - Use actions for external API calls
- Minimize action-to-query/mutation calls
File Storage
File Operations
// Get file URL
const url = await ctx.storage.getUrl(fileId);
// Get file metadata
const metadata = await ctx.db.system.get(fileId);
// File metadata type
type FileMetadata = {
_id: Id<"_storage">;
_creationTime: number;
contentType?: string;
sha256: string;
size: number;
};
Scheduling and Cron Jobs
Cron Job Definition
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
// Schedule recurring task
crons.interval(
"cleanup task",
{ hours: 2 },
internal.cleanup.removeOldData,
{},
);
// Cron expression
crons.cron(
"daily report",
"0 9 * * *", // 9 AM daily
internal.reports.generateDaily,
{},
);
export default crons;
TypeScript Best Practices
Type Safety with IDs
import { Id } from "./_generated/dataModel";
// Use specific ID types
function getUserData(userId: Id<"users">) {
return ctx.db.get(userId);
}
// Record with ID keys
const userMap: Record<Id<"users">, string> = {};
Discriminated Unions
// Use 'as const' for string literals
type Status = "active" | "inactive" | "pending";
const status = "active" as const;
Array and Record Types
// Explicit array typing
const items: Array<string> = ["a", "b", "c"];
// Explicit record typing
const mapping: Record<string, number> = { a: 1, b: 2 };
Error Handling
Common Error Patterns
// Document existence checks
const user = await ctx.db.get(userId);
if (!user) {
throw new Error("User not found");
}
// Validation errors
if (!args.email.includes("@")) {
throw new Error("Invalid email format");
}
Performance Guidelines
Query Optimization
- Always use indexes instead of
.filter() - Limit results with
.take(n)when possible - Use
.unique()for single document queries - Avoid nested queries in loops
Data Modeling
- Design indexes for your query patterns
- Keep document size under 1MB
- Limit arrays to 8192 items
- Use references for large related data
API Design Patterns
File Organization
convex/
├── schema.ts # Database schema
├── users.ts # User-related functions
├── messages.ts # Message operations
├── channels.ts # Channel management
├── crons.ts # Scheduled tasks
└── http.ts # HTTP endpoints
Function Naming
- Use descriptive verb-noun patterns:
createUser,getMessage - Group related functions in files
- Use internal functions for private operations
Common Pitfalls to Avoid
- Don't use
ctx.dbin actions - Don't use
.filter()instead of indexes - Don't forget argument and return validators
- Don't use
undefined(usenullinstead) - Don't pass functions directly to schedulers (use FunctionReference)
- Don't use deprecated methods like
ctx.storage.getMetadata
Example: Complete Chat Application
This example demonstrates a real-time chat system with AI responses:
// convex/schema.ts
export default defineSchema({
users: defineTable({ name: v.string() }),
channels: defineTable({ name: v.string() }),
messages: defineTable({
channelId: v.id("channels"),
authorId: v.optional(v.id("users")),
content: v.string(),
}).index("by_channel", ["channelId"]),
});
// convex/chat.ts
export const createUser = mutation({
args: { name: v.string() },
returns: v.id("users"),
handler: async (ctx, args) => {
return await ctx.db.insert("users", { name: args.name });
},
});
export const sendMessage = mutation({
args: {
channelId: v.id("channels"),
authorId: v.id("users"),
content: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.insert("messages", {
channelId: args.channelId,
authorId: args.authorId,
content: args.content,
});
// Schedule AI response
await ctx.scheduler.runAfter(0, internal.ai.generateResponse, {
channelId: args.channelId,
});
return null;
},
});
export const listMessages = query({
args: { channelId: v.id("channels") },
returns: v.array(
v.object({
_id: v.id("messages"),
_creationTime: v.number(),
content: v.string(),
authorName: v.optional(v.string()),
}),
),
handler: async (ctx, args) => {
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.take(10);
const result = [];
for (const message of messages) {
const user = message.authorId ? await ctx.db.get(message.authorId) : null;
result.push({
_id: message._id,
_creationTime: message._creationTime,
content: message.content,
authorName: user?.name,
});
}
return result;
},
});
Troubleshooting & Common Issues
Parameter Name Mismatches
Problem: Mutations fail with parameter validation errors
Root Cause: Frontend parameter names don't match Convex function arguments
Solution: Always check the function definition in convex/ folder:
// ❌ Frontend passes wrong parameter names
await uploadDocument({
name: file.name, // Function expects 'fileName'
size: file.size, // Function expects 'fileSize'
storageId: id, // Function expects 'fileId'
});
// ✅ Check convex/documents.ts for actual parameter names
export const uploadDocument = mutation({
args: {
fileName: v.string(), // Use 'fileName' not 'name'
fileSize: v.number(), // Use 'fileSize' not 'size'
mimeType: v.string(),
fileId: v.id("_storage"), // Use 'fileId' not 'storageId'
},
handler: async (ctx, args) => {
// implementation
},
});
// ✅ Correct frontend call
await uploadDocument({
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
fileId: storageId,
});
ID Type Validation
Problem: Type errors with Id<"tableName"> vs string
Solution: Use proper Convex ID types:
// ✅ Import correct types
import { Id } from "convex/values";
import { Doc } from "./_generated/dataModel";
// ✅ Define mutations with proper ID types
export const deleteDocument = mutation({
args: {
documentId: v.id("documents"), // This expects Id<"documents">
},
handler: async (ctx, args) => {
await ctx.db.delete(args.documentId);
},
});
// ✅ Frontend: Ensure IDs are typed correctly
interface Document {
_id: Id<"documents">; // Not string!
fileId: Id<"_storage">;
}
const handleDelete = async (documentId: Id<"documents">) => {
await deleteDocument({ documentId }); // Types match
};
Schema Alignment Issues
Problem: Runtime errors due to schema mismatches
Solution: Keep schema and function definitions synchronized:
// ✅ Schema definition (convex/schema.ts)
documents: defineTable({
userId: v.string(),
fileName: v.string(), // Use consistent naming
fileSize: v.number(),
mimeType: v.string(),
fileId: v.id("_storage"),
status: v.union(
v.literal("uploading"),
v.literal("processing"),
v.literal("ready"),
v.literal("error")
),
}).index("by_user", ["userId"]),
// ✅ Function args must match schema fields
export const uploadDocument = mutation({
args: {
fileName: v.string(), // Matches schema
fileSize: v.number(), // Matches schema
mimeType: v.string(), // Matches schema
fileId: v.id("_storage"), // Matches schema
},
handler: async (ctx, args) => {
await ctx.db.insert("documents", {
...args,
userId: await getUserId(ctx),
status: "processing",
uploadedAt: Date.now(),
});
},
});
Authentication Context Issues
Problem: getUserId() returns null in authenticated contexts
Common Causes:
- Missing authentication middleware
- Incorrect auth provider setup
- Using internal functions from public context
Solutions:
// ✅ Always handle authentication properly
export const createDocument = mutation({
args: { title: v.string() },
handler: async (ctx, args) => {
const userId = await getUserId(ctx);
if (!userId) {
throw new Error("Not authenticated"); // Explicit error
}
return await ctx.db.insert("documents", {
title: args.title,
userId,
createdAt: Date.now(),
});
},
});
// ✅ Use proper authentication checks
import { getAuthUserId } from "@convex-dev/auth/server";
export const secureFunction = mutation({
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx); // Throws if not authenticated
// Safe to use userId here
},
});
Agent Framework Integration
Problem: Agent responses not streaming or context not preserved
Solutions:
// ✅ Proper agent continuation pattern
export const generateStreamingResponse = internalAction({
args: { threadId: v.string(), promptMessageId: v.string() },
handler: async (ctx, args) => {
const agent = getAgent("chat"); // Get appropriate agent
// Continue existing thread (preserves context)
const { thread } = await agent.continueThread(ctx, {
threadId: args.threadId,
});
// Stream with proper settings
const result = await thread.streamText(
{ promptMessageId: args.promptMessageId },
{
saveStreamDeltas: {
chunking: "word", // Word-level streaming
throttleMs: 100, // Reasonable throttling
returnImmediately: true, // Don't wait for completion
},
},
);
await result.consumeStream(); // Ensure stream is processed
},
});
Performance & Query Optimization
Problem: Slow queries or excessive re-renders
Solutions:
// ✅ Use proper indexing
documents: defineTable({
userId: v.string(),
status: v.string(),
createdAt: v.number(),
})
.index("by_user", ["userId"]) // Query by user
.index("by_status", ["status"]) // Query by status
.index("by_user_status", ["userId", "status"]), // Compound queries
// ✅ Efficient queries with indexes
export const getUserActiveDocuments = query({
args: { userId: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query("documents")
.withIndex("by_user_status", (q) =>
q.eq("userId", args.userId).eq("status", "ready")
)
.order("desc")
.take(50); // Limit results
},
});
// ✅ Use pagination for large datasets
export const listDocuments = query({
args: {
paginationOpts: paginationOptsValidator
},
handler: async (ctx, args) => {
return await ctx.db
.query("documents")
.order("desc")
.paginate(args.paginationOpts);
},
});
Development Workflow Issues
Problem: Changes not reflecting or deployment issues
Debug Steps:
# 1. Check Convex deployment status
npx convex dashboard
# 2. Restart Convex development
pkill -f convex
npx convex dev
# 3. Verify schema deployment
npx convex run --show-schema
# 4. Check function deployment
npx convex ls functions
# 5. View real-time logs
npx convex logs --tail
Environment Setup Checklist:
-
.env.localcontainsCONVEX_DEPLOYMENT -
convex/auth.config.tsproperly configured - Schema changes deployed (
npx convex devrunning) - Auth providers configured if using authentication
- API keys set in Convex dashboard for external services
Follow these patterns consistently to build robust, scalable Convex applications that leverage the platform's strengths while avoiding common pitfalls.