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".
git clone https://github.com/majiayu000/claude-skill-registry
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"
skills/data/api-tier-architecture/SKILL.mdAPI 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
fromuseQuery
and@tanstack/react-query
fromuseQuery as useConvexQueryconvex/react - Check
before renderingshouldUseConvex() - Pass
to Convex query when disabled"skip" - 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)
for type recursion workaround@ts-ignore - Always wrap responses with
formatEntity(data, "entityName", id) - Verify ownership before mutations (call
first)getById - For mutations requiring
, usectx.authgetAuthenticatedConvexClient(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
(requires authentication) orwithAuthwithOptionalAuth - Wrap with
for consistent error responseswithErrorHandling - Parse body with
parseBody(req, zodSchema) - Always call
for monitoringtrackAPIPerformance - Use structured logging with
logger.info/warn/error - Return envelope-formatted responses via
formatEntity - Set
to prevent static optimizationdynamic = "force-dynamic"
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:
- ReturnscreateSSEResponse(){ response, send, sendError, close, isClosed }- Send initial snapshot with
await send("snapshot", data)
- Poll for updatescreatePollingLoop(pollFn, send, interval, eventName)
- Keep-alive every 2mincreateHeartbeatLoop(send, 120_000)
- Auto-cleanup on disconnectsetupSSECleanup(req.signal, close, [intervals])
Event types:
- Initial data payloadsnapshot
- Incremental updatesupdate
- Keep-alive ping (2min interval prevents mobile carrier timeout)heartbeat
- Error eventerror
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:
- Requires authentication, provideswithAuth(handler)
anduserIdsessionToken
- ProvideswithOptionalAuth(handler)
if authenticateduserId?: string- Always use
for error responsesformatErrorEntity - 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
- Platform detection logicapps/web/src/lib/utils/platform.ts
- Hybrid data hooksapps/web/src/lib/hooks/queries/
- DAL layer (server-only)apps/web/src/lib/api/dal/
- REST/SSE routesapps/web/src/app/api/v1/
- SSE utilitiesapps/web/src/lib/api/sse/utils.ts
- Auth middlewareapps/web/src/lib/api/middleware/auth.ts
Common Patterns
Creating new hybrid hook:
- Import both React Query and Convex query hooks
- Call
for platform detectionshouldUseConvex() - Conditionally enable queries with
or"skip"enabled: false - Return unified interface
Creating new REST endpoint:
- Create route in
apps/web/src/app/api/v1/{resource}/route.ts - Wrap handlers with
andwithAuthwithErrorHandling - Call DAL layer (never call Convex directly from routes)
- Return
responsesformatEntity - Set
dynamic = "force-dynamic"
Creating new SSE endpoint:
- Create route with
suffix/stream - Use
for connectioncreateSSEResponse() - Send
event immediatelysnapshot - Setup
for updatescreatePollingLoop - Setup
(2min interval)createHeartbeatLoop - Call
with intervalssetupSSECleanup
Adding DAL method:
- Create in
apps/web/src/lib/api/dal/{resource}.ts - Add
at topimport "server-only" - Validate input with Zod schemas
- Use
+(convex.mutation as any)
pattern@ts-ignore - Always
responsesformatEntity - 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
on API routesdynamic = "force-dynamic" - Don't skip heartbeat in SSE (mobile carriers timeout idle connections)
- Never use SSE for long operations (>30min) - use Convex actions instead