Skip to main content

API Client Module

The API client module (template/lib/api/) provides a comprehensive HTTP client layer for both client-side and server-side API communication. It includes an Axios-based client for browser use, a native fetch-based server client with caching and retries, specialized domain clients, and standardized error handling.

Architecture Overview

Source Files

FileDescription
lib/api/types.tsShared type definitions for API layer
lib/api/constants.tsAPI constants and query configuration
lib/api/api-client-class.tsApiClient -- Axios-based client for browser
lib/api/singleton.tsApiClientSingleton manager
lib/api/api-client.tsPre-built client instance and fetcher helpers
lib/api/server-api-client.tsServerClient -- fetch-based server client
lib/api/error-handler.tsStandardized API error handling
lib/api/lemonsqueezy-client.tsLemonSqueezy payment client
lib/api/survey-api.client.tsSurvey CRUD client

Type Definitions

Core Types

type ApiEndpoint = string;
type QueryParams = Record<string, string | number | boolean | undefined>;
type RequestBody = Record<string, unknown>;

interface PaginationParams {
page?: number;
limit?: number;
search?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}

Response Types (Discriminated Unions)

type ApiResponse<T = unknown> =
| { success: true; data: T; total?: number; page?: number; limit?: number; totalPages?: number }
| { success: false; error: string };

type PaginatedResponse<T> =
| { success: true; data: T[]; meta: { page: number; totalPages: number; total: number; limit: number } }
| { success: false; error: string };

Client Configuration

interface ApiClientConfig extends Partial<AxiosRequestConfig> {
baseURL?: string;
timeout?: number;
headers?: Record<string, string>;
accessToken?: string;
frontendUrl?: string;
}

interface ApiError {
message: string;
status?: number;
code?: string;
}

Client-Side: ApiClient

The ApiClient class wraps Axios with automatic token injection, response error handling, and typed responses.

Constructor

const client = new ApiClient({
baseURL: 'https://api.example.com',
accessToken: 'bearer-token',
headers: { 'X-Custom': 'value' },
});

HTTP Methods

All methods unwrap the ApiResponse envelope and return the data field directly:

// GET with query params
const items = await client.get<Item[]>('/items', { category: 'tools', limit: 10 });

// POST with body
const created = await client.post<Item>('/items', { name: 'New Tool', url: 'https://...' });

// PUT
const updated = await client.put<Item>('/items/123', { name: 'Updated' });

// PATCH
const patched = await client.patch<Item>('/items/123', { status: 'approved' });

// DELETE
await client.delete<void>('/items/123');

// Paginated GET
const page = await client.getPaginated<Item>('/items', { page: 1, limit: 20, search: 'react' });

Singleton Access

import { getApiClient } from '@/lib/api/singleton';

const client = getApiClient(); // Default instance
ApiClientSingleton.resetInstance(); // Reset (for tests)

Convenience Exports

import { apiClient, fetcherGet, fetcherPaginated } from '@/lib/api/api-client';

// Use with React Query / SWR
const data = await fetcherGet<Item[]>('/api/items', { status: 'published' });
const page = await fetcherPaginated<Item>('/api/items', { page: 1, limit: 20 });

Server-Side: ServerClient

The ServerClient class uses native fetch with timeout handling, automatic retries, LRU caching, and server-specific URL resolution.

Key Features

  • Timeout handling with AbortController (default: 30 seconds)
  • Automatic retries on network errors (default: 3 retries with 1s delay)
  • In-memory LRU cache for GET requests (100 entries, 5-minute TTL)
  • Server URL resolution for internal API routes during SSR
  • FormData support with automatic Content-Type handling

Pre-built Instances

import { serverClient, externalClient, createApiClient, recaptchaClient } from '@/lib/api/server-api-client';

// Default server client
const result = await serverClient.get<UserData>('/api/users/me');

// External API client (15s timeout, 2 retries)
const external = await externalClient.get<any>('https://api.third-party.com/data');

// Custom client
const customClient = createApiClient('https://api.service.com', { timeout: 10000 });

// ReCAPTCHA verification
const captcha = await recaptchaClient.verify(token);

HTTP Methods

// All methods return ApiResponse<T>
const result = await serverClient.get<T>(endpoint, options?);
const result = await serverClient.post<T>(endpoint, data?, options?);
const result = await serverClient.put<T>(endpoint, data?, options?);
const result = await serverClient.patch<T>(endpoint, data?, options?);
const result = await serverClient.delete<T>(endpoint, options?);

// File upload
const result = await serverClient.upload<T>(endpoint, fileOrFormData, options?);

// URL-encoded form data
const result = await serverClient.postForm<T>(endpoint, { key: 'value' }, options?);

Cache Control

serverClient.setCacheEnabled(false);   // Disable caching
serverClient.clearCache(); // Clear all cached responses
apiUtils.clearCache(); // Same via utility

Utility Functions

import { apiUtils } from '@/lib/api/server-api-client';

apiUtils.isSuccess(response); // Type guard
apiUtils.getErrorMessage(response); // Extract error
apiUtils.createQueryString({ page: 1, limit: 20 }); // 'page=1&limit=20'
apiUtils.buildUrl('/api/items', { page: 1, limit: 20 }); // '/api/items?page=1&limit=20'

Error Handling

HttpStatus Enum

enum HttpStatus {
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
FORBIDDEN = 403,
NOT_FOUND = 404,
METHOD_NOT_ALLOWED = 405,
CONFLICT = 409,
UNPROCESSABLE_ENTITY = 422,
INTERNAL_SERVER_ERROR = 500,
SERVICE_UNAVAILABLE = 503,
}

handleApiError(error, context?): NextResponse

Handles API route errors with automatic status code detection from error messages:

import { handleApiError } from '@/lib/api/error-handler';

export async function GET() {
try {
const data = await fetchData();
return NextResponse.json({ success: true, data });
} catch (error) {
return handleApiError(error, 'GET /api/items');
}
}

withErrorHandling(handler, context?): Promise

Higher-order function that wraps an async handler with error handling:

import { withErrorHandling } from '@/lib/api/error-handler';

export async function GET() {
return withErrorHandling(async () => {
const data = await fetchData();
return NextResponse.json({ success: true, data });
}, 'GET /api/items');
}

API Constants

const API_CONSTANTS = {
HEADERS: { CONTENT_TYPE: 'application/json', ACCEPT: 'application/json' },
STATUS: { UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, SERVER_ERROR: 500 },
DEFAULT_ERROR_MESSAGE: 'An unexpected error occurred',
};

const QUERY_CONFIG = {
staleTime: 300_000, // 5 minutes
gcTime: 86_400_000, // 1 day
retry: 1,
refetchOnWindowFocus: false,
};

Domain Clients

LemonSqueezyClient

import { lemonsqueezyClient } from '@/lib/api/lemonsqueezy-client';

const checkout = await lemonsqueezyClient.createCheckout({
variantId: 12345,
email: 'user@example.com',
customPrice: 4900,
});
// Returns: { checkoutUrl, email, customPrice, variantId, metadata }

const health = await lemonsqueezyClient.healthCheck();
const validation = lemonsqueezyClient.validateCheckoutParams(params);

SurveyApiClient

import { surveyApiClient } from '@/lib/api/survey-api.client';

const surveys = await surveyApiClient.getMany({ type: 'nps', status: 'active' });
const survey = await surveyApiClient.getOne('survey-id');
const created = await surveyApiClient.create({ title: 'Feedback', type: 'nps' });
await surveyApiClient.submitResponse({ surveyId: 'id', answers: [...] });
const responses = await surveyApiClient.getResponses('survey-id', { page: 1 });