Claude-skill-registry 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".

install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/api-tier-architecture" ~/.claude/skills/majiayu000-claude-skill-registry-api-tier-architecture && rm -rf "$T"
manifest: skills/data/api-tier-architecture/SKILL.md
source content

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

// 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:

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.

// 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.

// 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)

// 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.

// 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

// 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

CriteriaTier 1 (Convex)Tier 2 (SSE)Tier 3 (REST)
PlatformWeb desktopMobileMobile fallback
Latency<100ms real-time~5s updates30s cache
DurationUnlimited5-30min<30s
BatteryHigh (WebSocket)Medium (SSE)Low (polling)
Use casesChat messages, live listsProgress updates, streamingStandard 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