name: api-tier-architecture description: 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".
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
useQueryfrom@tanstack/react-queryanduseQuery as useConvexQueryfromconvex/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-ignorefor type recursion workaround - Always wrap responses with
formatEntity(data, "entityName", id) - Verify ownership before mutations (call
getByIdfirst) - For mutations requiring
ctx.auth, usegetAuthenticatedConvexClient(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) orwithOptionalAuth - Wrap with
withErrorHandlingfor consistent error responses - Parse body with
parseBody(req, zodSchema) - Always call
trackAPIPerformancefor 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:
createSSEResponse()- Returns{ response, send, sendError, close, isClosed }- Send initial snapshot with
await send("snapshot", data) createPollingLoop(pollFn, send, interval, eventName)- Poll for updatescreateHeartbeatLoop(send, 120_000)- Keep-alive every 2minsetupSSECleanup(req.signal, close, [intervals])- Auto-cleanup on disconnect
Event types:
snapshot- Initial data payloadupdate- Incremental updatesheartbeat- 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, providesuserIdandsessionTokenwithOptionalAuth(handler)- ProvidesuserId?: stringif authenticated- Always use
formatErrorEntityfor 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 logicapps/web/src/lib/hooks/queries/- Hybrid data hooksapps/web/src/lib/api/dal/- DAL layer (server-only)apps/web/src/app/api/v1/- REST/SSE routesapps/web/src/lib/api/sse/utils.ts- SSE utilitiesapps/web/src/lib/api/middleware/auth.ts- Auth middleware
Common Patterns
Creating new hybrid hook:
- Import both React Query and Convex query hooks
- Call
shouldUseConvex()for platform detection - Conditionally enable queries with
"skip"orenabled: false - Return unified interface
Creating new REST endpoint:
- Create route in
apps/web/src/app/api/v1/{resource}/route.ts - Wrap handlers with
withAuthandwithErrorHandling - Call DAL layer (never call Convex directly from routes)
- Return
formatEntityresponses - Set
dynamic = "force-dynamic"
Creating new SSE endpoint:
- Create route with
/streamsuffix - Use
createSSEResponse()for connection - Send
snapshotevent immediately - Setup
createPollingLoopfor updates - Setup
createHeartbeatLoop(2min interval) - Call
setupSSECleanupwith intervals
Adding DAL method:
- Create in
apps/web/src/lib/api/dal/{resource}.ts - Add
import "server-only"at top - Validate input with Zod schemas
- Use
(convex.mutation as any)+@ts-ignorepattern - Always
formatEntityresponses - 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