TinyKit Docs

API Reference

This document provides comprehensive reference for TinyKit SaaS's API patterns, function conventions, and development standards.

API Design Patterns

Function Organization

TinyKit SaaS follows a consistent domain-based organization pattern:

convex/
├── domain/
│   ├── public/              # Public API functions
│   │   ├── queries.ts       # Read operations (SELECT)
│   │   ├── mutations.ts     # Write operations (INSERT, UPDATE, DELETE)
│   │   └── actions.ts       # External API calls (Stripe, Resend, etc.)
│   ├── internal/            # Internal helper functions
│   ├── helpers.ts           # Domain utilities and permission checks
│   └── schema.ts           # Domain type definitions

Function Naming Conventions

Admin Functions (Suffix Pattern)

All admin functions follow the *Admin suffix pattern for clear identification:

// Queries
getAllNotificationsAdmin; // Get paginated notification history
getNotificationStatsAdmin; // System-wide statistics
searchUsersAdmin; // User search for admin interface
searchTeamsAdmin; // Team search for targeting

// Mutations
sendNotificationToAllUsersAdmin; // Broadcast to all users
sendNotificationToAllTeamOwnersAdmin; // Broadcast to team owners
bulkMarkAsReadAdmin; // Bulk operations
createTeamAdmin; // Admin-created teams

Permission-Based Function Names

// Role requirements implied by naming
requireAdmin*              // Admin only functions
requireTeamOwner*          // Team owner functions
requireTeamAdmin*          // Team admin/owner functions

Type Safety Patterns

Convex Validators

Always use Convex validators for runtime type checking:

import { v } from "convex/values";

export const createTeam = mutation({
  args: {
    name: v.string(),
    description: v.optional(v.string()),
    initialMembers: v.array(v.string()),
  },
  handler: async (ctx, args) => {
    // Runtime validation ensures args match exactly
  },
});

Generated Types

Use Convex generated types for complete type safety:

import type { Id } from "@/convex/_generated/dataModel";
import { api } from "@/convex/_generated/api";

// Use generated ID types
const teamId: Id<"teams"> = "team123" as Id<"teams">;

// Use generated API for function calls
const team = useQuery(api.teams.queries.getById, { id: teamId });

Authentication & Authorization

Core Access Control Functions

Location: convex/lib/access/ (modular policy-based access control with 7 specialized files)

// Import from modular access control system
import {
  requireAccess,
  hasAccess,
  requireAccessForAction,
  getUserRolePermissions,
  getOrgRolePermissions,
} from "@/convex/lib/access";

// Primary access control function - enforces requirements (requireAccess.ts)
export async function requireAccess(
  ctx: QueryCtx | MutationCtx,
  options: HasAccessOptions,
): Promise<AccessContext>;

// Conditional access checking - returns boolean (requireAccess.ts)
export async function hasAccess(
  ctx: QueryCtx | MutationCtx,
  options: HasAccessOptions,
): Promise<boolean>;

// Action-specific access control (requireAccessForAction.ts)
export async function requireAccessForAction(
  ctx: ActionCtx,
  options: HasAccessOptions,
): Promise<boolean>;

// Permission array generation utilities (utils.ts)
export function getUserRolePermissions(userRole: string | null): string[];
export function getOrgRolePermissions(orgRole: string | null): string[];

// Access options interface (types.ts) - supports arrays for multiple allowed roles
interface HasAccessOptions {
  userRole?: "admin" | "user" | Array<"admin" | "user">;
  orgRole?: "owner" | "admin" | "member" | Array<"owner" | "admin" | "member">;
  permission?: string; // "resource:action" format
  minPersonalAccessLevel?: number; // 0-3 (database-driven from products table)
  minOrgAccessLevel?: number; // 0-3 (database-driven from products table)
  orgId?: Id<"orgs">;
  condition?: (ctx: AccessContext) => boolean | Promise<boolean>;
}

// Access context with guaranteed non-null values (types.ts)
interface AccessContext {
  userId: Id<"users">; // Guaranteed non-null
  user: Doc<"users">; // Guaranteed non-null
  userRole: string; // User's role
  orgRole: string | null; // Org role if orgId provided
  personalAccessLevel: number; // Personal access level (0-3, database-driven)
  orgAccessLevel: number | null; // Org access level if orgId provided (database-driven)
}

Permission Array System

Location: Backend generates permission arrays, frontend uses for performance optimization

// Backend: Generate permission arrays in user queries
import {
  getUserRolePermissions,
  getOrgRolePermissions,
} from "@/convex/lib/access";

export const me = query({
  args: {},
  handler: async (ctx) => {
    const { userId, user, orgRole } = await requireAccess(ctx, {
      userRole: ["user"],
    });

    // Generate permission arrays for frontend
    const userPermissions = getUserRolePermissions(user.userRole || "user");
    const orgPermissions = getOrgRolePermissions(orgRole);

    return {
      ...user,
      permissions: userPermissions, // ["users:read", "users:update", ...]
      orgPermissions: orgPermissions, // ["orgMembers:invite", "messages:create", ...]
    };
  },
});

// Frontend: Fast permission checking using .includes()
const userData = useQuery(api.users.private.queries.getMe);

// Direct permission array checking (fastest)
const canDeleteUsers = userData?.permissions?.includes("users:delete") ?? false;
const canInviteMembers =
  userData?.orgPermissions?.includes("orgMembers:invite") ?? false;

Modular Architecture Files

Organization: Seven specialized files in convex/lib/access/

  1. index.ts - Main exports and comprehensive documentation
  2. types.ts - Type definitions (HasAccessOptions, AccessContext)
  3. requireAccess.ts - Primary access enforcement function
  4. requireAccessForAction.ts - Action-specific access control
  5. buildAccessContext.ts - Context building logic
  6. checkPermission.ts - Permission checking utilities
  7. utils.ts - Helper functions for permission arrays

Policy-Based Configuration

Location: convex/lib/permissions.ts (centralized policy configuration)

// User role permissions
export const userRolePermissions = {
  admin: { all: true },
  user: {
    profile: ["read", "update"],
    teams: ["create", "join"],
  },
} as const;

// Team role permissions
export const teamRolePermissions = {
  owner: { all: true },
  admin: {
    team: ["read", "update"],
    members: ["read", "invite", "remove"],
    content: ["create", "read", "update", "delete"],
  },
  member: {
    team: ["read"],
    content: ["create", "read"],
  },
} as const;

// Database-driven access levels (stored in products table)
// Access levels are no longer hardcoded but retrieved from products.accessLevel field
// This provides a single source of truth for subscription-based access control
export const accessLevels = {
  0: "Free", // Basic features
  1: "Basic", // Essential premium features
  2: "Pro", // Advanced features
  3: "Enterprise", // All features
} as const;

Access Control Implementation Patterns

Always use the policy-based access control system for protection:

export const protectedMutation = mutation({
  args: {
    teamId: v.id("teams"),
    data: v.object({}),
  },
  handler: async (ctx, args) => {
    // 1. Enforce access requirements FIRST (using modular system)
    const { userId, teamRole } = await requireAccess(ctx, {
      teamId: args.teamId,
      teamRole: "owner",
    });

    // 2. Guaranteed access - proceed with business logic
    const team = await ctx.db.get(args.teamId);
    if (!team) {
      throw new ConvexError("Team not found");
    }

    // 3. Execute operation with context
    return await ctx.db.patch(args.teamId, {
      ...args.data,
      updatedBy: userId,
      updatedAt: Date.now(),
    });
  },
});

// Conditional access example
export const getTeamData = query({
  args: { teamId: v.id("teams") },
  handler: async (ctx, args) => {
    // Check access without throwing
    const canAccess = await hasAccess(ctx, {
      teamId: args.teamId,
      teamRole: "member",
    });

    if (!canAccess) {
      return { error: "Team access required" };
    }

    return await ctx.db.get(args.teamId);
  },
});

// Complex access control example
export const upgradeTeamPlan = mutation({
  args: { teamId: v.id("teams"), planId: v.string() },
  handler: async (ctx, args) => {
    // Multiple access requirements in single call
    const { userId, orgRole } = await requireAccess(ctx, {
      orgId: args.orgId,
      orgRole: "owner", // Must be org owner
      minOrgAccessLevel: 1, // Must have paid subscription
    });

    // All requirements verified - proceed
    return await upgradeSubscription(args.teamId, args.planId);
  },
});

Utility Helper Functions

Billing & Subscription Helpers

The billing system provides essential helper functions for subscription management and access control:

Subscription Status Checking

// Enhanced subscription activity check with expiration handling
export function isSubscriptionActive(
  subscription: Doc<"subscriptions">,
): boolean {
  const isStatusActive =
    subscription.status === "active" || subscription.status === "trialing";

  // If status is not active, subscription is definitely not active
  if (!isStatusActive) {
    return false;
  }

  // If subscription is canceled at period end and has expired, it's not active
  if (subscription.cancelAtPeriodEnd && hasExpired(subscription)) {
    return false;
  }

  return true;
}

Policy-Based Access Control System

Function Names and Purpose

Clear, Descriptive Function Names:

  • requireAccess() - Enforces access requirements, throws on failure
  • requireAccessForAction() - Enforces access in Convex actions
  • Frontend hasAccess() - Checks access conditions, returns boolean

Core Access Control Functions

requireAccess() - Enforcement Function

Primary function for enforcing access control with guaranteed non-null results:

import { requireAccess } from "../../lib/access";

export const deleteUser = mutation({
  args: { userId: v.id("users") },
  handler: async (ctx, args) => {
    // Enforces access - throws descriptive error if denied
    const { userId: adminId } = await requireAccess(ctx, {
      userRole: "admin",
    });

    // Guaranteed access - proceed with operation
    await ctx.db.delete(args.userId);
    return { success: true };
  },
});

// Complex access control example
const { userId, orgRole, personalAccessLevel } = await requireAccess(ctx, {
  userRole: "user", // Must be authenticated
  orgId: args.orgId, // Organization context
  orgRole: "owner", // Must be org owner
  minPersonalAccessLevel: 1, // Must have premium subscription
  permission: "orgs:billing", // Must have billing permission
});

hasAccess() - Conditional Function

Returns boolean for conditional logic without throwing:

import { hasAccess } from "../../lib/access";

export const getAdminStats = query({
  args: {},
  handler: async (ctx) => {
    // Check access without throwing
    const isAdmin = await hasAccess(ctx, {
      userRole: "admin",
    });

    if (!isAdmin) {
      return { message: "Admin access required" };
    }

    return await generateAdminStatistics(ctx);
  },
});

// Multiple condition checks
const canManageOrg = await hasAccess(ctx, {
  orgId: args.orgId,
  orgRole: "owner",
});

const hasAdvancedFeatures = await hasAccess(ctx, {
  minPersonalAccessLevel: 1, // Premium subscription
});

requireAccessForAction() - Action Function

Specialized for Convex actions using internal queries:

import { requireAccessForAction } from "../../lib/access";

export const sendNotificationEmail = action({
  args: { userId: v.id("users"), message: v.string() },
  handler: async (ctx, args) => {
    // Actions require special handling
    const hasAccess = await requireAccessForAction(ctx, {
      permission: "notifications:send",
    });

    if (!hasAccess) {
      throw new Error("Insufficient permissions");
    }

    // Send email via Resend
    return await sendEmailViaResend(args.message);
  },
});

Usage Patterns

// Check if organization has access to a feature
const { orgAccessLevel } = await requireAccess(ctx, {
  orgId: args.orgId,
  minOrgAccessLevel: 2, // Enterprise tier
});

// Verify subscription is still active before allowing operation
const subscription = await ctx.db
  .query("subscriptions")
  .withIndex("by_org", (q) => q.eq("orgId", args.orgId))
  .first();

if (!subscription || !isSubscriptionActive(subscription)) {
  throw new ConvexError("Active subscription required");
}

Key Benefits

  • Real-time Expiration: No waiting for webhooks to revoke access
  • Graceful Cancellation: Users retain access until their paid period ends
  • Backward Compatibility: Existing code automatically benefits from enhanced checks
  • Consistent Logic: Same helper functions used across team and personal subscriptions

Rate Limiting API

requireRateLimit()

Protects mutations from abuse and prevents excessive resource usage.

Import:

import { requireRateLimit } from "../../lib/access";

Function Signature:

async function requireRateLimit(
  ctx: MutationCtx | QueryCtx | ActionCtx,
  limitType: string,
  options: {
    key: string | Id<TableNames>;
    count?: number;
    throws?: boolean;
  },
): Promise<{ ok: boolean; retryAfter?: number }>;

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

Usage Examples:

// Standard rate limiting
export const updateProfile = mutation({
  args: { name: v.string() },
  handler: async (ctx, args) => {
    const { userId } = await requireAccess(ctx, { userRole: ["user"] });
    await requireRateLimit(ctx, "profileUpdates", { key: userId });
    await ctx.db.patch(userId, { name: args.name });
  },
});

// Custom token consumption
export const processLargeRequest = mutation({
  args: { data: v.string(), estimatedCost: v.number() },
  handler: async (ctx, args) => {
    const { userId } = await requireAccess(ctx, { userRole: ["user"] });
    await requireRateLimit(ctx, "externalApiCalls", {
      key: userId,
      count: args.estimatedCost, // Consume multiple tokens
    });
    // Process request
  },
});

// Non-throwing check
export const checkUploadAvailability = query({
  args: {},
  handler: async (ctx) => {
    const { userId } = await requireAccess(ctx, { userRole: ["user"] });
    const { ok, retryAfter } = await requireRateLimit(ctx, "fileUploads", {
      key: userId,
      throws: false,
    });
    return { canUpload: ok, retryAfter };
  },
});

When to Use:

  • ✅ User-facing mutations (profile updates, content creation)
  • ✅ Authentication operations (sign in, sign up, password reset)
  • ✅ File uploads and expensive operations
  • ✅ External API calls
  • ✅ Admin operations (prevent accidental abuse)
  • ❌ Internal mutations (already protected)
  • ❌ Simple queries (unless computationally expensive)

Database Query Patterns

Efficient Query Design

Use Indexes for Performance

// Good: Use indexes for efficient queries
export const getTeamMessages = query({
  args: { teamId: v.id("teams") },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("messages")
      .withIndex("by_team", (q) => q.eq("teamId", args.teamId))
      .order("desc")
      .take(50);
  },
});

// Bad: Full table scan
export const getTeamMessagesSlow = query({
  args: { teamId: v.id("teams") },
  handler: async (ctx, args) => {
    const allMessages = await ctx.db.query("messages").collect();
    return allMessages.filter((m) => m.teamId === args.teamId);
  },
});

Pagination Patterns

// Cursor-based pagination for performance
export const getPaginatedMessages = query({
  args: {
    teamId: v.id("teams"),
    paginationOpts: paginationOptsValidator,
  },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("messages")
      .withIndex("by_team", (q) => q.eq("teamId", args.teamId))
      .order("desc")
      .paginate(args.paginationOpts);
  },
});

Database Schema Patterns

Relationship Design

// One-to-many: Team to Members
teams: {
  _id: Id<"teams">,
  name: string,
  slug: string,
  // ... other fields
}

teamMembers: {
  _id: Id<"teamMembers">,
  teamId: Id<"teams">,        // Foreign key to teams
  userId: Id<"users">,        // Foreign key to users
  teamRole: "owner" | "admin" | "member",
  joinedAt: number
}

// Indexes for efficient queries
teamMembers.byTeam: ["teamId"]
teamMembers.byUser: ["userId"]
teamMembers.byTeamAndUser: ["teamId", "userId"]

Error Handling Patterns

Consistent Error Responses

export const safeMutation = mutation({
  args: { data: v.object({}) },
  handler: async (ctx, args) => {
    try {
      // Validate permissions using policy-based access control
      const { userId } = await requireAccess(ctx, {
        role: "admin",
      });

      // Execute operation
      const result = await performOperation(ctx, args.data);

      return { success: true, data: result };
    } catch (error) {
      // Log for debugging
      logger.error("Operation failed:", error);

      // Return user-friendly error
      throw new ConvexError(
        error instanceof ConvexError
          ? error.message
          : "Operation failed. Please try again.",
      );
    }
  },
});

Frontend Error Handling

const MyComponent = () => {
  const mutation = useMutation(api.domain.mutations.safeMutation);

  const handleAction = async (data) => {
    try {
      const result = await mutation({ data });

      if (result.success) {
        toast.success("Operation completed successfully!");
        return result.data;
      }
    } catch (error) {
      toast.error(error.message || "Something went wrong");
      logger.error("Action failed:", error);
    }
  };

  return (
    <Button onClick={() => handleAction(formData)}>
      Execute Action
    </Button>
  );
};

Real-time Subscription Patterns

Reactive Data Pattern

// Component automatically updates when data changes
const TeamDashboard = ({ teamSlug }: { teamSlug: string }) => {
  // Real-time team data
  const team = useQuery(api.teams.queries.getBySlug, { slug: teamSlug });

  // Real-time messages
  const messages = useQuery(
    api.messages.queries.getTeamMessages,
    team?._id ? { teamId: team._id } : "skip"
  );

  // Real-time member list
  const members = useQuery(
    api.teams.queries.getTeamMembers,
    team?._id ? { teamId: team._id } : "skip"
  );

  if (!team) return <LoadingSkeleton />;

  return (
    <div>
      <TeamHeader team={team} />
      <MemberList members={members} />
      <MessageList messages={messages} />
    </div>
  );
};

Optimistic Updates

const useSendMessage = (teamId: Id<"teams">) => {
  const sendMessage = useMutation(api.messages.mutations.send);
  const [optimisticMessage, setOptimisticMessage] = useState<string | null>(
    null,
  );

  const handleSend = async (content: string) => {
    // Show optimistic update immediately
    setOptimisticMessage(content);

    try {
      await sendMessage({ teamId, content });
      // Success - optimistic update is replaced by real data
      setOptimisticMessage(null);
    } catch (error) {
      // Error - rollback optimistic update
      setOptimisticMessage(null);
      throw error;
    }
  };

  return { handleSend, optimisticMessage };
};

External API Integration Patterns

Stripe Integration (Actions)

// Stripe operations use actions for external API calls
export const createCheckoutSession = action({
  args: {
    teamId: v.id("teams"),
    priceId: v.string(),
    successUrl: v.string(),
    cancelUrl: v.string(),
  },
  handler: async (ctx, args) => {
    // Get team and validate permissions
    const team = await ctx.runQuery(api.teams.queries.getById, {
      id: args.teamId,
    });

    if (!team) {
      throw new ConvexError("Team not found");
    }

    // Check user permissions using policy-based access control
    const { userId } = await requireAccess(ctx, {
      teamId: args.teamId,
      teamRole: "owner",
    });

    // Create Stripe checkout session
    const session = await stripe.checkout.sessions.create({
      customer: team.stripeCustomerId,
      payment_method_types: ["card"],
      line_items: [
        {
          price: args.priceId,
          quantity: 1,
        },
      ],
      mode: "subscription",
      success_url: args.successUrl,
      cancel_url: args.cancelUrl,
    });

    return { sessionId: session.id, url: session.url };
  },
});

Email Integration (Actions)

// Email sending uses actions for external API calls
export const sendNotificationEmail = action({
  args: {
    to: v.string(),
    template: v.string(),
    data: v.object({}),
  },
  handler: async (ctx, args) => {
    // Render email template
    const emailHtml = await renderEmailTemplate(args.template, args.data);

    // Send via Resend
    const result = await resend.sendEmail(ctx, {
      from: "TinyKit SaaS <noreply@example.com>",
      to: args.to,
      subject: args.data.subject,
      html: emailHtml,
    });

    // Update database with delivery status
    await ctx.runMutation(api.notifications.mutations.updateEmailStatus, {
      notificationId: args.data.notificationId,
      emailSent: true,
      emailSentAt: Date.now(),
    });

    return result;
  },
});

Testing Patterns

Function Testing

// Test Convex functions with proper setup
describe("Team Management", () => {
  let convex: ConvexTestingHelper;

  beforeEach(async () => {
    convex = new ConvexTestingHelper();
    await convex.mutation(api.teams.mutations.create, {
      name: "Test Team",
      description: "A test team",
    });
  });

  it("should allow team owner to update settings", async () => {
    const teamId = await convex.mutation(api.teams.mutations.create, {
      name: "Test Team",
    });

    const result = await convex.mutation(api.teams.mutations.update, {
      teamId,
      updates: { name: "Updated Team" },
    });

    expect(result.success).toBe(true);
  });

  it("should deny non-owners from updating settings", async () => {
    // Test permission denial
    await expect(
      convex.mutation(api.teams.mutations.update, {
        teamId: "unauthorized-team",
        updates: { name: "Hacked Team" },
      }),
    ).rejects.toThrow("Insufficient permissions");
  });
});

Performance Optimization Patterns

Query Optimization

// Batch related queries efficiently
export const getTeamDashboardData = query({
  args: { teamId: v.id("teams") },
  handler: async (ctx, args) => {
    // Check permissions once using policy-based access control
    const canAccess = await hasAccess(ctx, {
      teamId: args.teamId,
      teamRole: "member",
    });
    if (!canAccess) {
      return null;
    }

    // Batch all required data
    const [team, members, recentMessages, stats] = await Promise.all([
      ctx.db.get(args.teamId),
      ctx.db
        .query("teamMembers")
        .withIndex("by_team", (q) => q.eq("teamId", args.teamId))
        .collect(),
      ctx.db
        .query("messages")
        .withIndex("by_team", (q) => q.eq("teamId", args.teamId))
        .order("desc")
        .take(10),
      getTeamStats(ctx, args.teamId),
    ]);

    return { team, members, recentMessages, stats };
  },
});

Frontend Performance

// Memoized components for expensive renders
const TeamMemberList = React.memo(({ members }: { members: TeamMember[] }) => {
  const sortedMembers = useMemo(() => {
    // Sort by role: owners first, then admins, then members
    const roleOrder = { owner: 0, admin: 1, member: 2 };
    return members.sort((a, b) =>
      roleOrder[a.teamRole as keyof typeof roleOrder] -
      roleOrder[b.teamRole as keyof typeof roleOrder]
    );
  }, [members]);

  return (
    <div>
      {sortedMembers.map(member => (
        <MemberCard key={member._id} member={member} />
      ))}
    </div>
  );
});

Security Patterns

Input Validation

// Server-side validation with Convex validators
export const createUser = mutation({
  args: {
    email: v.string(),
    firstName: v.string(),
    lastName: v.string(),
    userRole: v.union(v.literal("user"), v.literal("admin")),
  },
  handler: async (ctx, args) => {
    // Additional business logic validation
    if (!args.email.includes("@")) {
      throw new ConvexError("Invalid email address");
    }

    if (args.firstName.length < 1 || args.lastName.length < 1) {
      throw new ConvexError("Name fields are required");
    }

    // Permission check for admin role assignment
    if (args.userRole === "admin") {
      await requireAccess(ctx, {
        userRole: "admin",
      });
    }

    // Execute with validated data
    return await ctx.db.insert("users", args);
  },
});

Data Sanitization

// Sanitize user input for security
export const createAnnouncement = mutation({
  args: {
    title: v.string(),
    content: v.string(),
  },
  handler: async (ctx, args) => {
    // Sanitize inputs
    const sanitizedTitle = args.title.trim().slice(0, 100);
    const sanitizedContent = args.content.trim().slice(0, 1000);

    // Validate sanitized inputs
    if (!sanitizedTitle || !sanitizedContent) {
      throw new ConvexError("Title and content are required");
    }

    return await ctx.db.insert("announcements", {
      title: sanitizedTitle,
      content: sanitizedContent,
      createdAt: Date.now(),
    });
  },
});

User Profile Helper Functions

Picture URL Resolution

Location: convex/users/helpers.ts

The getUserPictureUrl helper provides consistent picture URL resolution with OAuth provider image support:

// Get user picture URL with proper fallback handling
export async function getUserPictureUrl(
  user: { pictureStorageId?: string; image?: string },
  ctx: { storage: { getUrl: (id: string) => Promise<string | null> } },
): Promise<string | null>;

Usage in 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) - Takes priority when users upload custom avatars
  2. OAuth provider images (image field) - GitHub, Google, Apple profile pictures
  3. Default state (null) - No image available

Implementation Benefits

  • OAuth Integration: Automatically displays GitHub, Google, and Apple profile pictures
  • Seamless Upgrades: User uploads override OAuth images without breaking functionality
  • Consistent API: All components use the same pictureUrl field regardless of image source
  • Performance Optimized: Single helper function used across all user queries
  • Type Safe: Proper TypeScript types for user objects and context

Next.js Configuration

OAuth profile images require Next.js image domain configuration:

// next.config.ts
images: {
  remotePatterns: [
    {
      protocol: "https",
      hostname: "avatars.githubusercontent.com", // GitHub
      pathname: "/**",
    },
    {
      protocol: "https",
      hostname: "lh3.googleusercontent.com", // Google
      pathname: "/**",
    },
    {
      protocol: "https",
      hostname: "appleid.cdn-apple.com", // Apple
      pathname: "/**",
    },
  ],
},

← Previous: Deployment | ← Back to Technical

On this page

Ship your startup faster. In minutes.

Get TinyKit SaaS