TinyKit Docs

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 .tsx extension
  • Convex Functions: Use .ts extension (queries, mutations, actions)
  • Email Templates: Use .tsx extension 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 _nav directories
  • 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: _nav prefix (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 (except src/app/ which uses Next.js App Router conventions)
  • emails/**/*.{ts,tsx} - KEBAB_CASE
  • scripts/**/*.ts - KEBAB_CASE
  • src/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:

  1. User-uploaded images (pictureStorageId) - User uploads take priority
  2. OAuth provider images (image field) - GitHub, Google, Apple profile pictures
  3. 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 component

Database-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 exports

Frontend 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

  1. Simplified Product Naming: Products use a single name field for both Stripe and UI display

    • No displayName field: 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
  2. Use helper functions from convex/billing/helpers.ts for subscription checks:

    • isSubscriptionActive() - Enhanced with expiration checking
    • hasSubscriptionExpired() - Check if subscription period has ended
    • getOrgAccessLevel() - Get organization's current access tier
    • getPersonalAccessLevel() - Get user's current access tier
  3. Always check subscription status AND expiration date together

  4. Handle graceful cancellation periods (access retained until currentPeriodEnd)

  5. Use real-time access checks rather than relying solely on webhooks

  6. 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 reset
  • profileUpdates - 5/min, burst 10 (token bucket) - Profile changes
  • contentCreation - 20/min, burst 30 (token bucket) - Posts, comments, messages
  • adminOperations - 100/hour, burst 150 (token bucket) - Admin actions
  • externalApiCalls - 50/hour, burst 100 (token bucket) - Third-party APIs
  • fileUploads - 10/hour, burst 20 (token bucket) - File storage
  • searchQueries - 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

On this page

Ship your startup faster. In minutes.

Get TinyKit SaaS