TypeScript
```typescript
// lib/api/types.ts
export class APIClientError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number,
public details?: Record
) {
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;
params?: Record;
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 | 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('/auth/refresh', {
method: 'POST',
body: { refreshToken: this.refreshToken },
skipRefresh: true, // Prevent infinite loop
}),
me: () =>
this.request('/auth/me', { method: 'GET' }),
};
users = {
get: (id: string) =>
this.request(/users/${id}, { method: 'GET' }),
update: (id: string, data: Partial) =>
this.request(/users/${id}, { method: 'PATCH', body: data }),
};
private async request(endpoint: string, options: RequestOptions): Promise {
const url = this.buildUrl(endpoint, options.params);
const headers: Record = {
'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 && !options.skipRefresh) {
const refreshed = await this.attemptTokenRefresh();
if (refreshed) {
return this.request(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(await response.json());
}
private async attemptTokenRefresh(): Promise {
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 {
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 {
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(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 {
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
```typescript
// 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);
},
});
}
```