api-client

TypeScript API Client

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "api-client" with this command: npx skills add dadbodgeoff/drift/dadbodgeoff-drift-api-client

TypeScript API Client

Centralized API client with typed namespaces, automatic token refresh, and TanStack Query integration.

When to Use This Skill

  • Building frontend applications that call backend APIs

  • Need type safety on requests and responses

  • Want automatic token refresh without duplicated logic

  • Using TanStack Query for caching and state management

Core Concepts

The pattern provides:

  • Typed namespaces (auth, users, billing, etc.)

  • Automatic token refresh with request deduplication

  • TanStack Query integration for caching

  • Consistent error handling with custom error class

Architecture:

Component → useQuery/useMutation → API Client → Fetch ↓ 401? → Refresh → Retry

Implementation

TypeScript

// lib/api/types.ts export class APIClientError extends Error { constructor( message: string, public code: string, public statusCode: number, public details?: Record<string, unknown> ) { super(message); this.name = 'APIClientError'; } }

export interface TokenPair { accessToken: string; refreshToken: string; expiresAt: string; }

// lib/api/client.ts interface RequestOptions { method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; body?: Record<string, unknown>; params?: Record<string, string | number | boolean | undefined>; skipRefresh?: boolean; }

export class APIClient { private baseUrl: string; private accessToken: string | null = null; private refreshToken: string | null = null; private onUnauthorized: () => void;

// Refresh deduplication private isRefreshing = false; private refreshPromise: Promise<boolean> | null = null;

constructor(options: { baseUrl: string; onUnauthorized?: () => void }) { this.baseUrl = options.baseUrl.replace(//$/, ''); this.onUnauthorized = options.onUnauthorized || (() => {}); }

setTokens(accessToken: string, refreshToken: string): void { this.accessToken = accessToken; this.refreshToken = refreshToken; }

clearTokens(): void { this.accessToken = null; this.refreshToken = null; }

// Typed namespaces auth = { login: (data: { email: string; password: string }) => this.request<{ tokens: TokenPair; user: User }>('/auth/login', { method: 'POST', body: data, }),

refresh: () =>
  this.request&#x3C;TokenPair>('/auth/refresh', {
    method: 'POST',
    body: { refreshToken: this.refreshToken },
    skipRefresh: true, // Prevent infinite loop
  }),

me: () =>
  this.request&#x3C;User>('/auth/me', { method: 'GET' }),

};

users = { get: (id: string) => this.request<User>(/users/${id}, { method: 'GET' }),

update: (id: string, data: Partial&#x3C;User>) =>
  this.request&#x3C;User>(`/users/${id}`, { method: 'PATCH', body: data }),

};

private async request<T>(endpoint: string, options: RequestOptions): Promise<T> { const url = this.buildUrl(endpoint, options.params);

const headers: Record&#x3C;string, string> = {
  'Content-Type': 'application/json',
};

if (this.accessToken) {
  headers['Authorization'] = `Bearer ${this.accessToken}`;
}

const response = await fetch(url, {
  method: options.method,
  headers,
  body: options.body ? JSON.stringify(options.body) : undefined,
});

// Handle 401 - attempt refresh
if (response.status === 401 &#x26;&#x26; !options.skipRefresh) {
  const refreshed = await this.attemptTokenRefresh();
  if (refreshed) {
    return this.request&#x3C;T>(endpoint, { ...options, skipRefresh: true });
  }
  this.onUnauthorized();
  throw new APIClientError('Unauthorized', 'UNAUTHORIZED', 401);
}

if (!response.ok) {
  throw await this.parseError(response);
}

if (response.status === 204) return undefined as T;
return this.transformResponse&#x3C;T>(await response.json());

}

private async attemptTokenRefresh(): Promise<boolean> { if (!this.refreshToken) return false;

// Deduplicate concurrent refresh attempts
if (this.isRefreshing) {
  return this.refreshPromise!;
}

this.isRefreshing = true;
this.refreshPromise = this.doRefresh();

try {
  return await this.refreshPromise;
} finally {
  this.isRefreshing = false;
  this.refreshPromise = null;
}

}

private async doRefresh(): Promise<boolean> { try { const tokens = await this.auth.refresh(); this.setTokens(tokens.accessToken, tokens.refreshToken); return true; } catch { this.clearTokens(); return false; } }

private buildUrl(endpoint: string, params?: Record<string, any>): string { const url = new URL(${this.baseUrl}${endpoint}); if (params) { Object.entries(params).forEach(([key, value]) => { if (value !== undefined) url.searchParams.set(key, String(value)); }); } return url.toString(); }

private transformResponse<T>(data: unknown): T { // Convert snake_case to camelCase return this.snakeToCamel(data) as T; }

private snakeToCamel(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(item => this.snakeToCamel(item)); if (obj !== null && typeof obj === 'object') { return Object.fromEntries( Object.entries(obj).map(([key, value]) => [ key.replace(/([a-z])/g, (, letter) => letter.toUpperCase()), this.snakeToCamel(value), ]) ); } return obj; }

private async parseError(response: Response): Promise<APIClientError> { try { const data = await response.json(); return new APIClientError( data.message || 'Request failed', data.code || 'UNKNOWN_ERROR', response.status, data.details ); } catch { return new APIClientError('Request failed', 'UNKNOWN_ERROR', response.status); } } }

// Singleton export export const apiClient = new APIClient({ baseUrl: process.env.NEXT_PUBLIC_API_URL || '/api', onUnauthorized: () => { if (typeof window !== 'undefined') window.location.href = '/login'; }, });

TanStack Query Integration

// lib/api/query-keys.ts export const queryKeys = { auth: { all: ['auth'] as const, me: () => [...queryKeys.auth.all, 'me'] as const, }, users: { all: ['users'] as const, detail: (id: string) => [...queryKeys.users.all, id] as const, }, } as const;

// lib/api/hooks/use-auth.ts import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

export function useCurrentUser() { return useQuery({ queryKey: queryKeys.auth.me(), queryFn: () => apiClient.auth.me(), staleTime: 5 * 60 * 1000, retry: false, }); }

export function useLogin() { const queryClient = useQueryClient();

return useMutation({ mutationFn: (data: { email: string; password: string }) => apiClient.auth.login(data), onSuccess: (response) => { apiClient.setTokens(response.tokens.accessToken, response.tokens.refreshToken); queryClient.setQueryData(queryKeys.auth.me(), response.user); }, }); }

Usage Examples

Component Usage

function UserProfile() { const { data: user, isLoading } = useCurrentUser(); const logout = useLogout();

if (isLoading) return <div>Loading...</div>;

return ( <div> <h2>{user?.displayName}</h2> <button onClick={() => logout.mutate()}>Logout</button> </div> ); }

Best Practices

  • Typed namespaces - Group related endpoints for discoverability

  • Token refresh deduplication - Prevent multiple concurrent refresh requests

  • Query key factory - Consistent cache key management

  • Response transformation - Convert snake_case to camelCase automatically

  • Singleton export - Single instance for consistent token state

Common Mistakes

  • Not deduplicating token refresh (causes race conditions)

  • Forgetting skipRefresh on refresh endpoint (infinite loop)

  • Scattered fetch calls without centralized error handling

  • No response transformation (inconsistent casing)

  • Creating multiple client instances (token state mismatch)

Related Patterns

  • jwt-auth - JWT authentication implementation

  • rate-limiting - Client-side rate limiting

  • error-handling - Error handling patterns

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

typescript-strict

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

ai-generation-client

No summary provided by upstream source.

Repository SourceNeeds Review
General

oauth-social-login

No summary provided by upstream source.

Repository SourceNeeds Review