When the frontend talks straight to the database (Supabase with RLS, Firebase), the API is whatever the tables happen to be. Business logic ends up in components, and moving to a real backend later means touching every screen.

Put four thin layers between the UI and the data, each with one job:

  component
     │  useVideo(id)
  query hook     ── caching, one per resource
     │  apiClient.getVideoForFeed(id)
  apiClient      ── the app's whole API, in one discoverable object
     │
  service        ── business logic: enrich, combine, decide
     │
  DAO            ── the only code that knows table names
     │
  database

Each layer

// daos/videoDao.ts
export const videoDao = {
  async getById(id: string) {
    const { data, error } = await db.from("videos").select("*").eq("id", id).single();
    if (error) throw error;
    return data;
  },
};

// services/videoService.ts
export const videoService = {
  getVideoForFeed: (id: string) => videoDao.getById(id),   // enrichment goes here
};

// lib/apiClient.ts
export const apiClient = {
  getVideoForFeed: videoService.getVideoForFeed,
};

// hooks/useVideo.ts
export const useVideo = (id: string) =>
  useQuery({
    queryKey: ["video", id],
    queryFn: () => apiClient.getVideoForFeed(id),
    enabled: !!id,
  });

Why it works

  • The swap is local. Moving off Supabase to REST or GraphQL changes the apiClient entries. Hooks and components don’t change.
  • Logic has one home. Services are plain functions, so they’re easy to test without React.
  • The API is discoverable. Everything the app can do is in apiClient.