Agent skill

api-tier-architecture

3-tier API architecture (Convex WebSocket, SSE, REST) for cross-platform data fetching. Platform detection, hybrid hooks, DAL layer patterns. Triggers on "API", "tier", "Convex", "REST", "SSE", "useConvexQuery", "useQuery", "withAuth", "DAL".

Stars 163
Forks 31

Install this agent skill to your Project

npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/data/api-tier-architecture

SKILL.md

API Tier Architecture

Three-tier API architecture for web (real-time) and mobile (battery-optimized) platforms.

Architecture Overview

Tier 1 (Web Desktop): Convex WebSocket - Real-time bidirectional subscription Tier 2 (Mobile): SSE - Server-Sent Events with polling (battery-optimized) Tier 3 (Mobile Fallback): REST - Standard HTTP polling

All tiers authenticated via withAuth middleware, data accessed via DAL layer.

Platform Detection

typescript
// From apps/web/src/lib/utils/platform.ts
export function shouldUseConvex(): boolean {
  return getDataFetchingStrategy() === "convex";
}

export function shouldUseSSE(): boolean {
  return getDataFetchingStrategy() === "sse";
}

// Detection hierarchy:
// 1. User-agent (iPhone, Android, mobile browsers)
// 2. Viewport width (< 768px)
// 3. Touch capability

Manual override for testing:

typescript
localStorage.setItem("blah_data_strategy", "convex"); // or "sse" or "polling"

Hybrid Hook Pattern

All data hooks use hybrid pattern: Convex for web, React Query for mobile.

typescript
// From apps/web/src/lib/hooks/queries/useConversations.ts
export function useConversations(options: UseConversationsOptions = {}) {
  const { page = 1, pageSize = 20, archived = false } = options;
  const useConvexMode = shouldUseConvex();
  const apiClient = useApiClient();

  // Tier 1: Convex WebSocket subscription (web desktop)
  const convexData = useConvexQuery(
    api.conversations.list,
    useConvexMode && !archived ? {} : "skip",
  );

  // Tier 2/3: REST API query (mobile)
  const restQuery = useQuery({
    queryKey: ["conversations", { page, pageSize, archived }],
    queryFn: async () => {
      const params = new URLSearchParams({
        page: String(page),
        pageSize: String(pageSize),
        archived: String(archived),
      });
      return apiClient.get(`/conversations?${params}`);
    },
    enabled: !useConvexMode,
    staleTime: 30_000, // 30s cache
  });

  // Return unified interface
  if (useConvexMode) {
    return {
      data: convexData ? { items: convexData, ... } : undefined,
      isLoading: convexData === undefined,
      error: null,
      refetch: () => Promise.resolve(),
    };
  }

  return {
    data: restQuery.data,
    isLoading: restQuery.isLoading,
    error: restQuery.error,
    refetch: restQuery.refetch,
  };
}

Key conventions:

  • Import both useQuery from @tanstack/react-query and useQuery as useConvexQuery from convex/react
  • Check shouldUseConvex() before rendering
  • Pass "skip" to Convex query when disabled
  • Return unified interface: { data, isLoading, error, refetch }

DAL Layer (Data Access Layer)

Server-only Convex client wrappers. Never import in client components.

typescript
// From apps/web/src/lib/api/dal/conversations.ts
import "server-only";

export const conversationsDAL = {
  create: async (_userId: string, data: CreateInput) => {
    const validated = createConversationSchema.parse(data);
    const convex = getConvexClient();

    const conversationId = (await (convex.mutation as any)(
      // @ts-ignore - TypeScript recursion limit with 94+ Convex modules
      api.conversations.create,
      { ...validated },
    )) as any;

    const conversation = (await (convex.query as any)(
      // @ts-ignore - TypeScript recursion limit with 94+ Convex modules
      api.conversations.get,
      { conversationId },
    )) as any;

    return formatEntity(conversation, "conversation", conversation._id);
  },

  getById: async (userId: string, conversationId: string) => {
    const convex = getConvexClient();

    // Uses clerkId for server-side ownership verification
    const conversation = (await (convex.query as any)(
      // @ts-ignore - TypeScript recursion limit with 94+ Convex modules
      api.conversations.getWithClerkVerification,
      { conversationId: conversationId as Id<"conversations">, clerkId: userId },
    )) as any;

    if (!conversation) {
      throw new Error("Conversation not found or access denied");
    }

    return formatEntity(conversation, "conversation", conversation._id);
  },

  // Always verify ownership before mutations
  update: async (userId: string, conversationId: string, data: UpdateInput) => {
    await conversationsDAL.getById(userId, conversationId); // Ownership check
    // ... perform mutation
  },
};

DAL conventions:

  • Always validate input with Zod schemas
  • Use (convex.mutation as any) + @ts-ignore for type recursion workaround
  • Always wrap responses with formatEntity(data, "entityName", id)
  • Verify ownership before mutations (call getById first)
  • For mutations requiring ctx.auth, use getAuthenticatedConvexClient(sessionToken)

REST API Routes (Tier 3)

typescript
// From apps/web/src/app/api/v1/conversations/route.ts
async function postHandler(req: NextRequest, { userId }: { userId: string }) {
  const startTime = performance.now();
  logger.info({ userId }, "POST /api/v1/conversations");

  const body = await parseBody(req, createSchema);
  const result = await conversationsDAL.create(userId, body);

  const duration = performance.now() - startTime;

  trackAPIPerformance({
    endpoint: "/api/v1/conversations",
    method: "POST",
    duration,
    status: 201,
    userId,
  });

  return NextResponse.json(result, { status: 201 });
}

async function getHandler(req: NextRequest, { userId }: { userId: string }) {
  const limit = Number.parseInt(getQueryParam(req, "limit") || "50", 10);
  const archived = getQueryParam(req, "archived") === "true";

  const conversations = await conversationsDAL.list(userId, limit, archived);

  return NextResponse.json(
    formatEntity({ items: conversations, total: conversations.length }, "list"),
    {
      headers: {
        "Cache-Control": getCacheControl(CachePresets.LIST), // 30s cache
      },
    },
  );
}

export const POST = withErrorHandling(withAuth(postHandler));
export const GET = withErrorHandling(withAuth(getHandler));
export const dynamic = "force-dynamic";

REST conventions:

  • Wrap handlers with withAuth (requires authentication) or withOptionalAuth
  • Wrap with withErrorHandling for consistent error responses
  • Parse body with parseBody(req, zodSchema)
  • Always call trackAPIPerformance for monitoring
  • Use structured logging with logger.info/warn/error
  • Return envelope-formatted responses via formatEntity
  • Set dynamic = "force-dynamic" to prevent static optimization

SSE Routes (Tier 2)

For medium-duration operations with real-time progress updates.

typescript
// From apps/web/src/app/api/v1/conversations/stream/route.ts
async function getHandler(req: NextRequest, { userId }: { userId: string }) {
  const convex = getConvexClient();

  // Create SSE connection
  const { response, send, sendError, close, isClosed } = createSSEResponse();

  try {
    // Send initial snapshot
    const initialData = await convex.query(api.conversations.list, {});
    await send("snapshot", { conversations: initialData });

    // Poll for updates every 5s
    const pollInterval = createPollingLoop(
      async () => {
        if (isClosed()) return null;
        const conversations = await convex.query(api.conversations.list, {});
        return { conversations };
      },
      send,
      5000, // 5s polling
      "update",
    );

    // Heartbeat every 2min (prevents mobile carrier disconnection)
    const heartbeat = createHeartbeatLoop(send, 120_000);

    // Setup cleanup on disconnect
    setupSSECleanup(req.signal, close, [pollInterval, heartbeat]);

    return response;
  } catch (error) {
    await sendError(error instanceof Error ? error : new Error(String(error)));
    await close();
    return new Response("Internal server error", { status: 500 });
  }
}

export const GET = withErrorHandling(withAuth(getHandler));

SSE patterns:

  1. createSSEResponse() - Returns { response, send, sendError, close, isClosed }
  2. Send initial snapshot with await send("snapshot", data)
  3. createPollingLoop(pollFn, send, interval, eventName) - Poll for updates
  4. createHeartbeatLoop(send, 120_000) - Keep-alive every 2min
  5. setupSSECleanup(req.signal, close, [intervals]) - Auto-cleanup on disconnect

Event types:

  • snapshot - Initial data payload
  • update - Incremental updates
  • heartbeat - Keep-alive ping (2min interval prevents mobile carrier timeout)
  • error - Error event

withAuth Middleware

typescript
// From apps/web/src/lib/api/middleware/auth.ts
export function withAuth(handler: AuthenticatedHandler) {
  return async (req: NextRequest, context: RouteContext) => {
    const { userId, getToken } = await auth();

    if (!userId) {
      return NextResponse.json(formatErrorEntity("Authentication required"), {
        status: 401,
      });
    }

    // Get session token for Convex authentication
    const sessionToken = await getToken({ template: "convex" });
    if (!sessionToken) {
      return NextResponse.json(
        formatErrorEntity("Session token unavailable"),
        { status: 401 },
      );
    }

    return await handler(req, { ...context, userId, sessionToken });
  };
}

Usage:

  • withAuth(handler) - Requires authentication, provides userId and sessionToken
  • withOptionalAuth(handler) - Provides userId?: string if authenticated
  • Always use formatErrorEntity for error responses
  • Session token needed for getAuthenticatedConvexClient(sessionToken)

Tier Selection Criteria

Criteria Tier 1 (Convex) Tier 2 (SSE) Tier 3 (REST)
Platform Web desktop Mobile Mobile fallback
Latency <100ms real-time ~5s updates 30s cache
Duration Unlimited 5-30min <30s
Battery High (WebSocket) Medium (SSE) Low (polling)
Use cases Chat messages, live lists Progress updates, streaming Standard CRUD

Key Files

  • apps/web/src/lib/utils/platform.ts - Platform detection logic
  • apps/web/src/lib/hooks/queries/ - Hybrid data hooks
  • apps/web/src/lib/api/dal/ - DAL layer (server-only)
  • apps/web/src/app/api/v1/ - REST/SSE routes
  • apps/web/src/lib/api/sse/utils.ts - SSE utilities
  • apps/web/src/lib/api/middleware/auth.ts - Auth middleware

Common Patterns

Creating new hybrid hook:

  1. Import both React Query and Convex query hooks
  2. Call shouldUseConvex() for platform detection
  3. Conditionally enable queries with "skip" or enabled: false
  4. Return unified interface

Creating new REST endpoint:

  1. Create route in apps/web/src/app/api/v1/{resource}/route.ts
  2. Wrap handlers with withAuth and withErrorHandling
  3. Call DAL layer (never call Convex directly from routes)
  4. Return formatEntity responses
  5. Set dynamic = "force-dynamic"

Creating new SSE endpoint:

  1. Create route with /stream suffix
  2. Use createSSEResponse() for connection
  3. Send snapshot event immediately
  4. Setup createPollingLoop for updates
  5. Setup createHeartbeatLoop (2min interval)
  6. Call setupSSECleanup with intervals

Adding DAL method:

  1. Create in apps/web/src/lib/api/dal/{resource}.ts
  2. Add import "server-only" at top
  3. Validate input with Zod schemas
  4. Use (convex.mutation as any) + @ts-ignore pattern
  5. Always formatEntity responses
  6. Verify ownership before mutations

Avoid

  • Never call Convex directly from client components on mobile (use hooks)
  • Never skip ownership verification in DAL mutations
  • Never return raw Convex data (always use formatEntity)
  • Don't forget dynamic = "force-dynamic" on API routes
  • Don't skip heartbeat in SSE (mobile carriers timeout idle connections)
  • Never use SSE for long operations (>30min) - use Convex actions instead

Didn't find tool you were looking for?

Be as detailed as possible for better results