api-tier-architecture

star 7

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

planetaryescape By planetaryescape schedule Updated 2/21/2026

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

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
Install via CLI
npx skills add https://github.com/planetaryescape/blah.chat --skill api-tier-architecture
Repository Details
star Stars 7
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
planetaryescape
planetaryescape Explore all skills →