Development Patterns
This document contains detailed development patterns, coding standards, and implementation examples for TinyKit Pro.
This document contains detailed development patterns, coding standards, and implementation examples for TinyKit Pro.
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/ - Shared Components: Use reusable building blocks from
@/features/shared/ - Feature Components: Organize by domain in
@/features/[domain]/ - Form Components: Combine React Hook Form + Zod + shadcn/ui from
@/features/shared/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 bun 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.string() }, // Better Auth uses string IDs
handler: async (ctx, { userId }) => {
const user = await getUserById(ctx, userId); // Use helper for Better Auth users
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:
// - requireAccess.ts: requireAccess() and hasAccess() functions
// - requireAccessForAction.ts: Action-specific access control
// - statements.ts: Better Auth role configuration
// - orgPermissions.ts: Organization role hierarchy
// - types.ts: Type definitions (HasAccessOptions, AccessContext)
// - index.ts: Main exportsFrontend Access Control:
// Frontend - role-based access control
import { useAccess } from "@/hooks/use-access";
const { hasAccess } = useAccess();
// Role-based checking
const isAdmin = 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.string() }, // Better Auth uses string IDs
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