Skip to Content
Iris Saas Kit documentation is under construction.
TechnicalConvex Backend

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

⚡ 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 deployment
  • JWKS and JWT_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


Next Steps

  1. Understand Custom Wrappers - Master the authentication layer system
  2. Implement Domain Logic - Follow the domain-based file organization
  3. Add Helper Functions - Create consistent utility patterns
  4. Setup Triggers - Configure database event handling
  5. Optimize Queries - Use proper indexing for workspace-scoped operations
  6. 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.

Last updated on