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:
- Attaches the
Authorization: Bearer <token>header automatically, reading the current auth token. - Sets
Content-Type: application/jsonby default. - Runs requests through a circuit breaker — if the backend is failing broadly,
makeRequestfails fast instead of piling up retries against a service that's already down.
handleApiResponse — unwrapping the backend's envelope
Recall Step 8's handlers return Ok(ApiResponse::success(tags)). On the frontend, handleApiResponse<T>(response):
- If
!response.ok, reads the backend's error body — checking both{ message }and{ error }shapes, since some older routes and the canonicalErrorResponse(Step 8) differ — and throws a typedApiError. - Otherwise parses the JSON body as
ApiResponse<T>and, ifresult.successistrue, returns justresult.data— unwrapped. This is whytagsApi.list()'s return type isPromise<Tag[]>, notPromise<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.