A social knowledge tool for researchers built on ATProto
API Client Architecture#
This document outlines the recommended approach for creating an API client that abstracts away the details of interacting with the backend API.
Recommended Approach: Separate Shared Package#
Create a separate npm package (e.g., @yourapp/api-client or @yourapp/shared) that contains:
- API Client - The abstraction layer with methods like
addUrlToLibrary() - Shared Types - DTOs, response types, error types
- Type Guards/Validators - Runtime type checking utilities
Why This Approach?#
Pros:
- Single source of truth for API contracts
- Type safety between frontend and backend
- Reusable across multiple frontends (web, mobile, etc.)
- Versioned - can manage API changes cleanly
- Testable in isolation
Cons:
- Additional build/publish complexity
- Need to manage package versioning
Package Structure#
packages/
├── api-client/
│ ├── src/
│ │ ├── client/
│ │ │ └── ApiClient.ts
│ │ ├── types/
│ │ │ ├── cards.ts
│ │ │ ├── collections.ts
│ │ │ └── common.ts
│ │ └── index.ts
│ └── package.json
├── backend/
└── frontend/
Example API Client Structure#
// packages/api-client/src/types/cards.ts
export interface AddUrlToLibraryRequest {
url: string;
note?: string;
collectionIds?: string[];
}
export interface AddUrlToLibraryResponse {
urlCardId: string;
noteCardId?: string;
}
// packages/api-client/src/client/ApiClient.ts
export class ApiClient {
constructor(
private baseUrl: string,
private getAuthToken: () => string | null,
) {}
async addUrlToLibrary(
request: AddUrlToLibraryRequest,
): Promise<AddUrlToLibraryResponse> {
const response = await this.post('/api/cards/library/urls', request);
return response.json();
}
async createCollection(
request: CreateCollectionRequest,
): Promise<CreateCollectionResponse> {
const response = await this.post('/api/collections', request);
return response.json();
}
private async post(endpoint: string, data: any) {
const token = this.getAuthToken();
return fetch(`${this.baseUrl}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
},
body: JSON.stringify(data),
});
}
}
Type Sharing Strategy#
Extract from your existing backend types:
- Take the DTO interfaces from your use cases
- Create clean, frontend-friendly versions
- Add any additional client-side types needed
// Extract these from your backend use cases:
export interface AddUrlToLibraryDTO {
url: string;
note?: string;
collectionIds?: string[];
curatorId: string; // This might be handled automatically by the client
}
// Transform to client-friendly version:
export interface AddUrlToLibraryRequest {
url: string;
note?: string;
collectionIds?: string[];
// curatorId removed - handled by auth
}
Alternative Approaches#
1. Frontend-Only Client#
- Keep API client in frontend repo
- Duplicate types or use code generation
- Simpler but less maintainable
2. Backend Exports Client#
- Export client from backend
- Frontend imports it
- Can work but creates coupling
3. Code Generation#
- Generate client from OpenAPI spec
- Tools like
openapi-generatororswagger-codegen - Great for large APIs but adds complexity
Implementation Steps#
- Create the package structure
- Extract and clean up types from your use case DTOs
- Build the ApiClient class with methods matching your endpoints
- Add error handling and response transformation
- Set up build/publish pipeline
- Update frontend to use the client
Error Handling Example#
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public code?: string
) {
super(message);
this.name = 'ApiError';
}
}
// In ApiClient
private async handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new ApiError(
error.message || 'Request failed',
response.status,
error.code
);
}
return response.json();
}
This approach gives you the best balance of maintainability, type safety, and reusability.