Step 10 — The API Client: tagsApi in lib/api.ts

Step 8's Axum routes accept and return JSON over HTTP; Step 9's shared/types.ts describes that JSON's shape in TypeScript. vibe-frontend/src/lib/api.ts is the layer that actually makes the HTTP calls and hands back typed data — every feature in iKanban has a small object like tagsApi here.

tagsApi — one function per backend route

export const tagsApi = {
  list: async (params?: TagSearchParams): Promise<Tag[]> => {
    const queryParams = new URLSearchParams();
    if (params?.team_id) queryParams.set('team_id', params.team_id);
    const response = await makeRequest(`/api/tags?${queryParams.toString()}`);
    return handleApiResponse<Tag[]>(response);
  },

  create: async (data: CreateTag): Promise<Tag> => {
    const response = await makeRequest('/api/tags', {
      method: 'POST',
      body: JSON.stringify(data),
    });
    return handleApiResponse<Tag>(response);
  },

  update: async (tagId: string, data: UpdateTag): Promise<Tag> => {
    const response = await makeRequest(`/api/tags/${tagId}`, {
      method: 'PUT',
      body: JSON.stringify(data),
    });
    return handleApiResponse<Tag>(response);
  },

  delete: async (tagId: string): Promise<void> => {
    const response = await makeRequest(`/api/tags/${tagId}`, { method: 'DELETE' });
    return handleApiResponse<void>(response);
  },
};

Notice the one-to-one match with Step 8's router: GET /tags, POST /tags, PUT /tags/{tag_id}, DELETE /tags/{tag_id} on the Rust side become tagsApi.list, .create, .update, .delete here — same four operations, same argument shapes (CreateTag in, Tag out for create; UpdateTag + an id in, Tag out for update), imported straight from Step 9's shared/types.

makeRequest — the shared fetch wrapper

Every tagsApi method calls makeRequest, not fetch directly. makeRequest (defined once, higher up in the same file) is where cross-cutting concerns live so individual API objects like tagsApi don't have to repeat them:

handleApiResponse — unwrapping the backend's envelope

Recall Step 8's handlers return Ok(ApiResponse::success(tags)). On the frontend, handleApiResponse<T>(response):

  1. If !response.ok, reads the backend's error body — checking both { message } and { error } shapes, since some older routes and the canonical ErrorResponse (Step 8) differ — and throws a typed ApiError.
  2. Otherwise parses the JSON body as ApiResponse<T> and, if result.success is true, returns just result.data — unwrapped. This is why tagsApi.list()'s return type is Promise<Tag[]>, not Promise<ApiResponse<Tag[]>>: every caller works with the plain data, never the envelope.

This is the pattern referenced by CLAUDE.md's guidance to "verify API shape at the call-site" — any new frontend code consuming an endpoint should check a sibling xApi object in this same file rather than guess whether a response needs unwrapping.

Checkpoint

Find TagSearchParams in shared/types.ts (used by tagsApi.list's parameter) and compare its optional search/team_id fields to ListTagsQuery from Step 8's Rust route — same two query-string parameters, described independently on each side of the network boundary, exactly like Tag/Tag (Rust) from Step 9.

Continue to Step 11 — The React Hook: useTags with TanStack Query, where tagsApi gets wired into a component-friendly hook.