Development Patterns
This document contains detailed development patterns, coding standards, and implementation examples for TinyKit SaaS.
Code Style & Conventions
File Extensions & Organization
- React Components: Use
.tsxextension - Convex Functions: Use
.tsextension (queries, mutations, actions) - Email Templates: Use
.tsxextension with React Email components - Follow Existing Patterns: Always check neighboring files for consistency
Admin Function Pattern
All admin functions follow the *Admin suffix pattern:
// Examples:
(getAllNotificationsAdmin,
sendNotificationToAllUsersAdmin,
searchUsersAdmin,
searchOrgsAdmin);Import Conventions
// Path aliases (configured in tsconfig.json)
import { Button } from "@/components/ui/button";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
// Convex function imports
import { query, mutation, action } from "./_generated/server";
import { v } from "convex/values";
// Modular access control imports
import { useAccess } from "@/hooks/useAccess";
import {
requireAccess,
hasAccess,
getUserRolePermissions,
} from "../../lib/access"; // Backend
import { convertRoleToDisplayName } from "@/lib/formatters";Semantic Color System
Always use semantic colors instead of hardcoded Tailwind colors:
// Good: Semantic colors that adapt to themes
className = "bg-primary text-primary-foreground";
className = "bg-card border-border text-card-foreground";
// Bad: Hardcoded Tailwind colors
className = "bg-blue-500 text-white";
className = "bg-gray-100 border-gray-200 text-gray-900";Component Patterns
- UI Components: Use shadcn/ui from
@/components/ui/ - Core Components: Use building blocks from
@/components/tinykit/core/ - Feature Components: Organize by domain in
@/components/tinykit/features/[domain]/ - Form Components: Combine React Hook Form + Zod + shadcn/ui from
@/components/tinykit/forms/ - Navigation Components: Co-locate with routes in
_navdirectories - Permission Wrapping: Use helper functions for conditional rendering
- Email Components: Use React Email with semantic color integration
Naming Conventions
- Files: kebab-case (
org-settings.tsx,notification-history.tsx) - Feature Directories: kebab-case (
access-demo,site-branding,admin-dashboard) - Navigation Directories:
_navprefix (e.g.,src/app/admin/_nav/) - Components: PascalCase (
OrgSettings,NotificationHistory,AdminSidebar) - Functions: camelCase (
getUserOrgRole,sendNotificationToAllUsers) - Constants: UPPER_SNAKE_CASE (
STRIPE_PLANS,NOTIFICATION_TYPES) - Types: PascalCase (
OrgMember,NotificationType)
ESLint Enforcement: File and folder naming is enforced via eslint-plugin-check-file:
src/**/*.{ts,tsx}- KEBAB_CASE (exceptsrc/app/which uses Next.js App Router conventions)emails/**/*.{ts,tsx}- KEBAB_CASEscripts/**/*.ts- KEBAB_CASEsrc/app/folders excluded from enforcement (supports route groups(), parallel routes@, intercepting routes(.))
Run pnpm lint to check for naming violations.
Form Handling Patterns
Standard Form Architecture
// 1. Zod schema for validation
const createOrgSchema = z.object({
name: z.string().min(1, "Organization name is required"),
description: z.string().optional(),
});
type CreateOrgForm = z.infer<typeof createOrgSchema>;
// 2. React Hook Form setup
const form = useForm<CreateOrgForm>({
resolver: zodResolver(createOrgSchema),
defaultValues: { name: "", description: "" },
});
// 3. Convex mutation with server-side validation
export const createOrg = mutation({
args: {
name: v.string(),
description: v.optional(v.string()),
},
handler: async (ctx, args) => {
// Permission checks first using unified access control
const { userId } = await requireAccess(ctx, {
userRole: "user",
});
// Business logic
const orgId = await ctx.db.insert("orgs", {
name: args.name,
description: args.description,
slug: generateSlug(args.name),
createdAt: Date.now(),
});
return orgId;
},
});Three-Tier Function Development Patterns
// PUBLIC FUNCTIONS (convex/module/public/)
// No authentication required, minimal functions for security
export const getWaitlistSettings = query({
args: {},
handler: async (ctx) => {
// No authentication check needed
const settings = await ctx.db.query("waitlistSettings").first();
return {
waitlistEnabled: settings?.waitlistEnabled ?? true,
showWaitlistCount: settings?.showWaitlistCount ?? false,
};
},
});
// PRIVATE FUNCTIONS (convex/module/private/)
// Always require authentication, all user-facing features
export const getUserProfile = query({
args: {},
handler: async (ctx) => {
// Always check authentication using modular access control
const { userId, user } = await requireAccess(ctx, {
userRole: ["user"], // Basic authentication required
});
// Pre-calculate permission arrays for frontend
const userPermissions = getUserRolePermissions(user.userRole || "user");
const orgPermissions: string[] = []; // Will be populated with current org context
return {
...user,
permissions: userPermissions,
orgPermissions,
};
},
});
// INTERNAL FUNCTIONS (convex/module/internal/)
// Backend-only, called via ctx.runQuery/ctx.runMutation/ctx.runAction
export const sendNotificationEmail = action({
args: { email: v.string(), subject: v.string(), content: v.string() },
handler: async (ctx, args) => {
// Internal function - no direct frontend access
// Called from other Convex functions like:
// await ctx.runAction(internal.notifications.internal.actions.sendNotificationEmail, {...});
return await sendEmailViaResend(args);
},
});OAuth Image Display Patterns
Location: convex/users/helpers.ts
Use the getUserPictureUrl helper for consistent user picture handling across all queries:
import { getUserPictureUrl } from "../helpers";
export const getUser = query({
args: { userId: v.id("users") },
handler: async (ctx, { userId }) => {
const user = await ctx.db.get(userId);
if (!user) return null;
// Handles both uploaded and OAuth images automatically
const pictureUrl = await getUserPictureUrl(user, ctx);
return {
...user,
pictureUrl,
};
},
});Image Priority System:
- User-uploaded images (
pictureStorageId) - User uploads take priority - OAuth provider images (
imagefield) - GitHub, Google, Apple profile pictures - Default state (
null) - No image available
Email Template Development
Directory Organization: All email templates are organized into logical subdirectories under /emails/:
emails/
├── auth/ # Authentication emails (verification, password reset)
├── notifications/ # System notifications and announcements
├── billing/ # Subscription and payment emails
├── organization/ # Team and role management emails
├── onboarding/ # Welcome and user invitation emails
└── common/ # Shared layout components
├── Layout.tsx # Main email layout with theming
├── Header.tsx # Email header component
└── Footer.tsx # Email footer componentDatabase-Driven Theming: All templates use unified email configuration for consistent branding:
// In Convex auth providers - unified query for performance
const emailConfig = await ctx.runQuery(
internal.siteSettings.internal.queries.getEmailConfig,
);
// Pass to email template
react: VerificationCodeEmail({
code: token,
expires: new Date(Date.now() + 20 * 60 * 1000),
themeColors: emailConfig.lightThemeColors, // Database theme colors
siteName: emailConfig.siteName, // Dynamic site name
});Semantic Color Requirements: Always use semantic theme variables instead of hardcoded Tailwind colors:
// ✅ Good: Semantic colors that adapt to themes
<Section className="bg-primary/10 border-2 border-primary rounded-md">
<Text className="text-primary font-bold">{title}</Text>
<Text className="text-muted-foreground">{message}</Text>
</Section>
// ❌ Bad: Hardcoded Tailwind colors
<Section className="bg-blue-50 border-2 border-blue-500 rounded-md">
<Text className="text-blue-800 font-bold">{title}</Text>
<Text className="text-gray-600">{message}</Text>
</Section>Access Control Patterns
Modular Access Control Architecture
Backend Modular Structure (convex/lib/access/):
// Modular backend imports
import {
requireAccess,
hasAccess,
getUserRolePermissions,
} from "../../lib/access";
// Individual modules for different concerns:
// - requireAccess.ts: Main access enforcement
// - requireAccessForAction.ts: Action-specific access control
// - buildAccessContext.ts: Context building logic
// - checkPermission.ts: Permission checking utilities
// - utils.ts: Helper functions for permission arrays
// - types.ts: Type definitions
// - index.ts: Main exportsFrontend Permission Array System:
// Frontend - permission array based access control
import { useAccess } from "@/hooks/useAccess";
const { hasAccess, userData } = useAccess();
// Direct permission checking using pre-calculated arrays
if (userData.permissions?.includes("users:delete")) {
// User can delete users
}
// Role-based checking (still supported)
const canManageUsers = hasAccess({ userRole: "admin" });
const canAccessAdmin = hasAccess({ userRole: ["admin"] });
// Organization-specific access
const canEditOrg = hasAccess({ orgId: org._id, orgRole: "owner" });
// Performance-optimized permission checking
const canDeleteUsers = userData.permissions?.includes("users:delete") ?? false;
const canManageOrgs = userData.orgPermissions?.includes("orgs:update") ?? false;
// Conditional rendering with permission arrays
{userData.permissions?.includes("admin:access") && <AdminPanel />}
{canEditOrg && <OrgSettings />}Organization Management Patterns
// Leave organization with owner restrictions
export const leaveOrg = mutation({
args: { orgId: v.id("orgs") },
handler: async (ctx, { orgId }) => {
const { userId } = await requireAccess(ctx, {});
const membership = await ctx.db
.query("orgMembers")
.withIndex("by_user_and_org", (q) =>
q.eq("userId", userId).eq("orgId", orgId),
)
.first();
if (!membership) {
throwConvexError("NOT_MEMBER_OF_ORG");
}
// Prevent owners from leaving
if (membership.orgRole === "owner") {
throw new Error(
"Organization owners cannot leave. Please transfer ownership or delete the organization instead.",
);
}
await ctx.db.delete(membership._id);
return { success: true };
},
});Storage Management Patterns
Convex Storage Cleanup Pattern
// Pattern: Always clean up old files when updating storage references
export const updateStorageReference = mutation({
args: { newStorageId: v.id("_storage") },
handler: async (ctx, { newStorageId }) => {
const { userId } = await requireAccess(ctx, { userRole: ["admin"] });
// Get existing settings/record
const existing = await ctx.db.query("tableName").first();
// Delete old storage file if it exists
const oldStorageId = existing?.storageId;
if (oldStorageId) {
try {
await ctx.storage.delete(oldStorageId);
} catch (error) {
// Log but don't fail - old file might already be deleted
logger.warn("Failed to delete old storage file:", error);
}
}
// Update with new storage ID
await ctx.db.patch(existing._id, {
storageId: newStorageId,
updatedBy: userId,
updatedAt: Date.now(),
});
},
});Billing System Development
-
Simplified Product Naming: Products use a single
namefield for both Stripe and UI display- No
displayNamefield: Removed from database schema, Stripe configuration, and frontend components - Unified naming: Single source of truth for product names across the entire system
- Cleaner data model: Reduces complexity in product management and sync operations
- No
-
Use helper functions from
convex/billing/helpers.tsfor subscription checks:isSubscriptionActive()- Enhanced with expiration checkinghasSubscriptionExpired()- Check if subscription period has endedgetOrgAccessLevel()- Get organization's current access tiergetPersonalAccessLevel()- Get user's current access tier
-
Always check subscription status AND expiration date together
-
Handle graceful cancellation periods (access retained until
currentPeriodEnd) -
Use real-time access checks rather than relying solely on webhooks
-
Follow established access level patterns (0=free, 1=professional, 2=enterprise)
React Key Collision Resolution: Use unique identifiers for dynamic lists:
// Best Practice: Use unique document IDs as keys
key={product._id}
// For composite scenarios: Combine multiple unique fields
key={`${plan.name}-${plan.type}`}
// Admin Filter Pattern: Use product IDs as filter values
// Instead of filtering by product name (which can duplicate across types),
// filter by product ID for guaranteed uniqueness
const planOptions = products.map(product => ({
value: product._id, // Unique ID as value
label: product.name, // Display name as label
}));
// Prevents React key collisions when products share names across types
// (e.g., "Free" plan exists for both "personal" and "org" types)Why Use IDs Over Names for Filters:
- Document IDs are always unique (React never throws duplicate key warnings)
- More maintainable (works even if product names change)
- Type-safe with
Id<"products">in TypeScript - Better performance (database lookups by ID are O(1))
Rate Limiting Patterns
IMPORTANT: Always use requireRateLimit() to protect mutations from abuse and prevent excessive resource usage.
Standard Rate Limiting Pattern
import { requireAccess, requireRateLimit } from "../../lib/access";
export const updateProfile = mutation({
args: { name: v.string(), bio: v.optional(v.string()) },
handler: async (ctx, args) => {
// 1. Access control first
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
// 2. Rate limit based on userId (5 updates/min with burst capacity of 10)
await requireRateLimit(ctx, "profileUpdates", { key: userId });
// 3. Business logic
await ctx.db.patch(userId, {
name: args.name,
bio: args.bio,
});
return { success: true };
},
});Pre-configured Rate Limits
authAttempts- 10/hour (fixed window) - Sign in, sign up, password resetprofileUpdates- 5/min, burst 10 (token bucket) - Profile changescontentCreation- 20/min, burst 30 (token bucket) - Posts, comments, messagesadminOperations- 100/hour, burst 150 (token bucket) - Admin actionsexternalApiCalls- 50/hour, burst 100 (token bucket) - Third-party APIsfileUploads- 10/hour, burst 20 (token bucket) - File storagesearchQueries- 30/min, burst 50 (token bucket) - Search operations
Advanced Rate Limiting Options
// Custom token consumption (e.g., LLM API with variable cost)
export const processAIRequest = action({
args: { prompt: v.string(), estimatedTokens: v.number() },
handler: async (ctx, args) => {
const { userId } = await requireAccessForAction(ctx, {
userRole: ["user"],
});
// Consume multiple tokens based on request size
await requireRateLimit(ctx, "externalApiCalls", {
key: userId,
count: args.estimatedTokens,
});
// Process AI request
return await callExternalAI(args.prompt);
},
});
// Check limit without throwing
export const checkUploadAvailability = query({
args: {},
handler: async (ctx) => {
const { userId } = await requireAccess(ctx, { userRole: ["user"] });
// Check if user can upload without throwing error
const { ok, retryAfter } = await requireRateLimit(ctx, "fileUploads", {
key: userId,
throws: false, // Don't throw on limit exceeded
});
return {
canUpload: ok,
retryAfter: retryAfter ?? null,
};
},
});When to Use Rate Limiting
✅ Always use for:
- User-facing mutations (profile updates, content creation)
- Authentication operations (sign in, sign up, password reset)
- File uploads and expensive operations
- External API calls (third-party integrations)
- Admin operations (to prevent accidental abuse)
❌ Don't use for:
- Internal mutations (already protected by function visibility)
- Simple queries (unless computationally expensive)
- Read-only operations with no side effects