API Reference
This document provides comprehensive reference for TinyKit Pro's API patterns, function conventions, and development standards.
This document provides comprehensive reference for TinyKit Pro's API patterns, function conventions, and development standards.
API Design Patterns
Function Organization
TinyKit Pro 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 definitionsFunction 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 teamsPermission-Based Function Names
// Role requirements implied by naming
requireAdmin* // Admin only functions
requireTeamOwner* // Team owner functions
requireTeamAdmin* // Team admin/owner functionsType 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: Six specialized files in convex/lib/access/
index.ts- Main exports (requireAccess, hasAccess, etc.)types.ts- Type definitions (HasAccessOptions, AccessContext)requireAccess.ts- requireAccess() (throws) and hasAccess() (returns boolean)requireAccessForAction.ts- Action-specific access controlstatements.ts- Better Auth role configurationorgPermissions.ts- Organization role hierarchy
Simplified Permission System
TinyKit Pro uses a simplified permission model for performance:
// User role permissions - simplified to ["*"] or []
export function getUserRolePermissions(role: string): string[] {
return role === "admin" ? ["*"] : [];
}
// 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 failurerequireAccessForAction()- 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.string() },
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.string(), 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);
},
});Migration from Old Function Names
Before (unclear naming):
// Old names were ambiguous about function behavior
await protect(ctx, { userRoles: ["admin"] }); // What does "protect" mean?
const access = await hasAccessForAction(ctx, options); // Inconsistent namingAfter (clear, descriptive naming):
// New names clearly indicate function purpose
const { userId } = await requireAccess(ctx, {
// Clearly requires access
userRole: "admin",
});
const hasPermission = await requireAccessForAction(ctx, {
// Consistent naming
permission: "users:delete",
});
const canAccess = await hasAccess(ctx, {
// Returns boolean
orgId: org._id,
orgRole: "owner",
});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 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
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
console.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");
console.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 Pro <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.string() },
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) - Takes priority when users upload custom avatars - OAuth provider images (
imagefield) - GitHub, Google, Apple profile pictures - 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
pictureUrlfield 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: "/**",
},
],
},