Convex Backend - Database Setup
This guide covers how Convex is architected in this codebase for scale, including the custom function wrapper system, domain-based organization, and multi-tenant patterns built for production SaaS applications.
🚀 Getting Started with This Convex Setup
Cloud Development (Recommended)
⚡ We strongly recommend using Convex cloud development for the best development experience, especially when working with webhooks and external integrations.
1. Deploy to Cloud Development Environment
# Initialize and deploy to Convex cloud development
npx convex dev --once
npx convex deploy
Key Benefits of Cloud Development:
- 🔗 Direct Webhook Handling - No need for ngrok or other tunneling tools
- 🌐 Public URLs - Your functions are accessible via HTTPS endpoints
- ⚡ Real-time Collaboration - Team members can access the same development environment
- 🔄 Automatic Deployments - Changes deploy instantly to a real cloud environment
- 📊 Production-like Environment - Test in conditions closer to production
2. Why Cloud Development for SaaS Applications
Webhook Integration Made Simple:
# With cloud development, your webhook URLs are immediately available:
# Stripe webhooks: https://your-deployment.convex.cloud/stripe/webhook
# OAuth callbacks: https://your-deployment.convex.cloud/auth/callback
# No tunneling tools required! 🎉
Benefits for This SaaS Boilerplate:
- Stripe Webhooks - Direct integration without ngrok setup
- OAuth Providers - Real callback URLs for Google/GitHub authentication
- Email Webhooks - Resend and other email service callbacks work directly
- Team Development - Share the same backend URL across your team
- Mobile Testing - Test from real mobile devices without network configuration
3. Setup Authentication
# Configure Convex Auth with OAuth providers
npx @convex-dev/auth
This automatically configures:
SITE_URL
environment variable pointing to your cloud deploymentJWKS
andJWT_PRIVATE_KEY
for token validation- OAuth provider callbacks using real HTTPS URLs
- Foundation for production-ready OAuth integration
4. Environment Configuration
# Set environment variables for your cloud development environment
npx convex env set DEVELOPER_ROLE_PASSKEY=your-passkey
npx convex env set AUTH_RESEND_KEY=your-resend-key
npx convex env set STRIPE_SECRET_KEY=your-stripe-key
npx convex env set STRIPE_WEBHOOK_SECRET=your-webhook-secret
Alternative: Local Development
If you prefer local development (though not recommended for webhook testing):
# Traditional local development
npx convex dev
⚠️ Limitations of Local Development:
- Webhook Testing - Requires ngrok or similar tunneling tools
- OAuth Setup - More complex callback URL configuration
- Team Collaboration - Each developer needs their own local setup
- Mobile Testing - Requires additional network configuration
Best Practice: Hybrid Approach
# Use cloud development for main work
npx convex deploy
# Use local development only for offline work or specific debugging
npx convex dev --offline
🗄️ Schema Architecture & Organization
Modular Schema Design
Following Convex schema best practices , the schema is organized modularly:
// convex/schema.ts - Main schema entry point
import { defineSchema } from 'convex/server';
import { authTables } from '@convex-dev/auth/server';
import coreSchema from './schema/core';
import featureSchema from './schema/feat';
const schema = defineSchema({
...authTables, // Convex Auth tables
...coreSchema, // Core business tables
...featureSchema, // Feature-specific tables
});
export default schema;
Core Schema Tables
The convex/schema/core.ts
contains production-ready table definitions optimized for multi-tenant SaaS:
// convex/schema/core.ts
import { defineTable } from 'convex/server';
import { v } from 'convex/values';
const coreSchema = {
users: defineTable({
email: v.string(),
name: v.string(),
picture: v.optional(v.id('_storage')),
isDeveloper: v.optional(v.boolean()), // Super admin access
}).index('email', ['email']),
workspaces: defineTable({
name: v.string(),
slug: v.string(),
logo: v.optional(v.id('_storage')),
createdBy: v.optional(v.id('users')),
isArchived: v.boolean(),
devNotes: v.optional(v.string()),
})
.index('createdBy', ['createdBy'])
.index('slug', ['slug', 'isArchived'])
.searchIndex('name', {
searchField: 'name',
filterFields: ['isArchived'],
}),
workspaceMembers: defineTable({
workspaceSlug: v.string(),
userId: v.id('users'),
picture: v.optional(v.id('_storage')),
email: v.string(),
name: v.string(),
isRemoved: v.boolean(),
role: workspaceMemberRoleValidator,
})
.index('workspaceSlug', ['workspaceSlug', 'role', 'isRemoved'])
.index('userId', ['userId', 'workspaceSlug', 'isRemoved']),
// Additional tables for subscriptions, notifications, etc.
};
Index Strategy for Scale
The indexing strategy is designed for efficient multi-tenant queries:
// Compound indexes for workspace-scoped queries
.index("workspaceSlug", ["workspaceSlug", "role", "isRemoved"])
.index("userId", ["userId", "workspaceSlug", "isRemoved"])
// Search indexes for user-facing features
.searchIndex("name", {
searchField: "name",
filterFields: ["isArchived"],
})
📁 Domain-Based File Organization
Core Directory Structure
convex/
├── _generated/ # Auto-generated by Convex
├── core/ # Domain-based business logic
│ ├── users.queries.ts # User read operations
│ ├── users.mutations.ts # User write operations
│ ├── users.helpers.ts # User utility functions
│ ├── users.validators.ts # User input validation
│ ├── workspaces.queries.ts
│ ├── workspaces.mutations.ts
│ ├── workspaces.helpers.ts
│ ├── workspaceMembers.queries.ts
│ ├── workspaceMembers.mutations.ts
│ ├── workspaceMembers.helpers.ts
│ ├── subscriptions.*.ts
│ └── stripe.*.ts
├── schema/
│ ├── core.ts # Core tables
│ └── feat.ts # Feature tables
├── _emailTemplates/ # Email templates
├── auth.ts # Authentication config
├── functions.ts # Custom function wrappers
├── triggers.config.ts # Database triggers
└── envs.ts # Environment variables
Domain File Naming Pattern
Each domain follows consistent naming:
{domain}.queries.ts
- Read operations{domain}.mutations.ts
- Write operations{domain}.helpers.ts
- Shared utilities{domain}.validators.ts
- Input validation{domain}.devQueries.ts
- Developer-only queries{domain}.devMutations.ts
- Developer-only mutations
🔧 Custom Function Wrapper System
Layered Authentication Architecture
The convex/functions.ts
provides a sophisticated authentication system built with convex-helpers
:
// convex/functions.ts
import {
customCtx,
customMutation,
customQuery,
} from 'convex-helpers/server/customFunctions';
import { ConvexError, v } from 'convex/values';
import { getAuthUserIdOrThrow, getAuthUserOrThrow } from './core/users.helpers';
import { getCurrentUserWorkspaceMemberHelper } from './core/workspaceMembers.helpers';
import { triggers } from './triggers.config';
// Basic authentication - provides currentUserId
export const authedQuery = customQuery(rawQuery, {
args: { userId: v.optional(v.id('users')) },
input: async (ctx, args) => {
const currentUserId = await getAuthUserIdOrThrow(ctx, args.userId);
return { ctx: { ...ctx, currentUserId }, args };
},
});
// User context - provides full currentUser object
export const userQuery = customQuery(rawQuery, {
args: { userId: v.optional(v.id('users')) },
input: async (ctx, args) => {
const currentUser = await getAuthUserOrThrow(ctx, args.userId);
return { ctx: { ...ctx, currentUser }, args };
},
});
// Developer access - enforces isDeveloper role
export const developerQuery = customQuery(rawQuery, {
args: { userId: v.optional(v.id('users')) },
input: async (ctx, args) => {
const currentUser = await getAuthUserOrThrow(ctx, args.userId);
if (!currentUser.isDeveloper) {
throw new ConvexError('You are not a developer');
}
return { ctx: { ...ctx, currentUser }, args };
},
});
// Workspace-scoped - provides workspace member context
export const workspaceMemberQuery = customQuery(rawQuery, {
args: {
workspaceSlug: v.string(),
userId: v.optional(v.id('users')),
},
input: async (ctx, args) => {
const currentUser = await getAuthUserOrThrow(ctx, args.userId);
const workspaceMember = await getCurrentUserWorkspaceMemberHelper(
ctx,
args.workspaceSlug,
args.userId
);
return {
ctx: {
...ctx,
currentUser,
workspaceMember,
workspaceSlug: args.workspaceSlug,
},
args,
};
},
});
Trigger System Integration
Mutations include a custom trigger system for database event handling:
// Base mutations with trigger integration
export const mutation = customMutation(rawMutation, customCtx(triggers.wrapDB));
export const internalMutation = customMutation(
rawInternalMutation,
customCtx(triggers.wrapDB)
);
// All mutation wrappers inherit trigger functionality
export const userMutation = customMutation(mutation, {
args: { userId: v.optional(v.id('users')) },
input: async (ctx, args) => {
const currentUser = await getAuthUserOrThrow(ctx, args.userId);
return { ctx: { ...ctx, currentUser }, args };
},
});
📊 Production Query Patterns
Current User Query with File Storage
// convex/core/users.queries.ts
import { userQuery } from '../functions';
export const getCurrentUser = userQuery({
args: {
includeDetail: v.optional(v.array(v.literal('picture'))),
},
handler: async (ctx, args) => {
const currentUser = ctx.currentUser;
return {
...currentUser,
profileUrl:
args.includeDetail?.includes('picture') && currentUser.picture
? await ctx.storage.getUrl(currentUser.picture)
: null,
};
},
});
Workspace Queries with Optional Detail Loading
// convex/core/workspaces.queries.ts
export const getWorkspaceBySlug = query({
args: {
slug: v.string(),
includeDetail: v.optional(
v.array(v.union(v.literal('members'), v.literal('subscription')))
),
},
handler: async (ctx, args) => {
const _workspace = await getWorkspaceBySlugHelper(ctx, args.slug);
const workspace = {
workspace: {
..._workspace,
logoUrl: _workspace.logo
? await ctx.storage.getUrl(_workspace.logo)
: null,
},
members: [],
subscription: null,
};
// Conditional data loading based on client needs
if (args.includeDetail?.includes('members')) {
const members = await ctx.db
.query('workspaceMembers')
.withIndex('workspaceSlug', q => q.eq('workspaceSlug', args.slug))
.collect();
workspace.members = await Promise.all(
members.map(async member => ({
...member,
pictureUrl: member.picture
? await ctx.storage.getUrl(member.picture)
: null,
}))
);
}
if (args.includeDetail?.includes('subscription')) {
workspace.subscription = await ctx.db
.query('workspaceSubscription')
.withIndex('workspaceSlug', q => q.eq('workspaceSlug', args.slug))
.unique();
}
return workspace;
},
});
User Workspaces with Optimized Loading
// convex/core/workspaces.queries.ts
export const getUserWorkspaces = authedQuery({
args: {},
handler: async ctx => {
const userId = ctx.currentUserId;
const workspacesMembers = await ctx.db
.query('workspaceMembers')
.withIndex('userId', q => q.eq('userId', userId))
.collect();
const workspacePromise = workspacesMembers.map(async member => {
const workspace = await ctx.db
.query('workspaces')
.withIndex('slug', q =>
q.eq('slug', member.workspaceSlug).eq('isArchived', false)
)
.unique();
return {
...workspace,
logoUrl: workspace?.logo
? await ctx.storage.getUrl(workspace.logo)
: null,
member,
};
});
return await Promise.all(workspacePromise);
},
});
✏️ Production Mutation Patterns
Profile Updates with File Management
// convex/core/users.mutations.ts
export const updateProfile = userMutation({
args: {
name: v.optional(v.string()),
picture: v.optional(v.id('_storage')),
},
handler: async (ctx, args) => {
const updateData: Partial<{
name: string;
picture: Id<'_storage'>;
}> = {};
if (args.name !== undefined) {
updateData.name = args.name;
}
if (args.picture !== undefined) {
// Get current user to check existing picture
const currentUser = await ctx.db.get(ctx.currentUser._id);
// Delete old picture if it exists
if (currentUser?.picture && currentUser.picture !== args.picture) {
try {
await ctx.storage.delete(currentUser.picture);
} catch (error) {
console.error('Failed to delete old profile picture:', error);
}
}
updateData.picture = args.picture;
}
if (Object.keys(updateData).length === 0) {
throw new Error('At least one field must be provided for update');
}
await ctx.db.patch(ctx.currentUser._id, updateData);
return ctx.currentUser._id;
},
});
Developer Role Management
// convex/core/users.mutations.ts
export const updateUserToDeveloperRole = mutation({
args: {
userId: v.id('users'),
passkey: v.string(),
},
handler: async (ctx, { userId, passkey }) => {
if (passkey !== DEVELOPER_ROLE_PASSKEY) {
throw new Error('Invalid passkey');
}
await ctx.db.patch(userId, {
isDeveloper: true,
});
},
});
🔄 Helper Functions for Consistency
Authentication Helpers
// convex/core/users.helpers.ts
export async function getAuthUserIdOrThrow(ctx: any, userId?: Id<'users'>) {
if (userId) return userId;
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new ConvexError('Not authenticated');
}
const user = await ctx.db
.query('users')
.withIndex('email', q => q.eq('email', identity.email))
.unique();
if (!user) {
throw new ConvexError('User not found');
}
return user._id;
}
export async function getAuthUserOrThrow(ctx: any, userId?: Id<'users'>) {
const currentUserId = await getAuthUserIdOrThrow(ctx, userId);
const user = await ctx.db.get(currentUserId);
if (!user) {
throw new ConvexError('User not found');
}
return user;
}
Workspace Member Helpers
// convex/core/workspaceMembers.helpers.ts
export async function getCurrentUserWorkspaceMemberHelper(
ctx: any,
workspaceSlug: string,
userId?: Id<'users'>
) {
const currentUserId = await getAuthUserIdOrThrow(ctx, userId);
const workspaceMember = await ctx.db
.query('workspaceMembers')
.withIndex('userId', q =>
q
.eq('userId', currentUserId)
.eq('workspaceSlug', workspaceSlug)
.eq('isRemoved', false)
)
.unique();
if (!workspaceMember) {
throw new ConvexError('Access denied to workspace');
}
return workspaceMember;
}
🎯 Workspace-Scoped Operations
Workspace Member Queries
// convex/core/workspaceMembers.queries.ts
export const getWorkspaceMemberByWorkspaceSlugAndUserId = workspaceMemberQuery({
args: {
includeWorkspace: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const { workspaceMember, workspaceSlug } = ctx;
let workspace: Doc<'workspaces'> | null = null;
if (args.includeWorkspace) {
workspace = await ctx.db
.query('workspaces')
.withIndex('slug', q => q.eq('slug', workspaceSlug))
.unique();
}
return {
member: workspaceMember,
workspace,
};
},
});
Workspace Access Validation
// convex/core/workspaces.queries.ts
export const validateWorkspaceAccess = workspaceMemberQuery({
args: {},
handler: async (ctx, args) => {
const { workspaceMember } = ctx;
return !!workspaceMember;
},
});
📈 Performance & Scaling Patterns
Efficient Data Loading
// Load related data in parallel
const workspacePromise = workspacesMembers.map(async member => {
const workspace = await ctx.db
.query('workspaces')
.withIndex('slug', q =>
q.eq('slug', member.workspaceSlug).eq('isArchived', false)
)
.unique();
return {
...workspace,
logoUrl: workspace?.logo ? await ctx.storage.getUrl(workspace.logo) : null,
member,
};
});
return await Promise.all(workspacePromise);
Index Usage for Scale
// Always use proper indexes for multi-tenant queries
const members = await ctx.db
.query('workspaceMembers')
.withIndex('workspaceSlug', q =>
q.eq('workspaceSlug', slug).eq('isRemoved', false)
)
.collect();
🔗 Additional Resources
- Official Convex Documentation
- Convex Database Guide
- Reading Data Patterns
- Schema Design
- Convex Helpers Library
Next Steps
- Understand Custom Wrappers - Master the authentication layer system
- Implement Domain Logic - Follow the domain-based file organization
- Add Helper Functions - Create consistent utility patterns
- Setup Triggers - Configure database event handling
- Optimize Queries - Use proper indexing for workspace-scoped operations
- Test Access Control - Validate multi-tenant security patterns
This Convex architecture provides enterprise-grade patterns for building scalable, multi-tenant SaaS applications with proper separation of concerns and robust access control.