Integrating third-party APIs is one of the most common tasks in web development, and also one of the most commonly done poorly. A naive implementation that works in development breaks in production under load, network blips, or API provider outages.
1. Generate a Typed API Client from an OpenAPI Spec
npm install -D @hey-api/openapi-ts
npx openapi-ts \
--input https://api.example.com/openapi.json \
--output ./src/lib/api-client \
--client fetch
# Usage — fully typed, no hand-written types
import { getUser, createOrder } from '@/lib/api-client/services';
const user = await getUser({ path: { userId: '123' } });2. Authentication Patterns
// API Key — always use headers, never query strings (they appear in server logs)
const response = await fetch('https://api.example.com/data', {
headers: { 'Authorization': `Bearer ${process.env.API_KEY}` },
});
// OAuth 2.0 Client Credentials — with token caching
class OAuth2Client {
private token: string | null = null;
private expiresAt = 0;
async getToken(): Promise<string> {
if (this.token && Date.now() < this.expiresAt - 30_000) return this.token!;
const res = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
body: new URLSearchParams({ grant_type: 'client_credentials', client_id: process.env.CLIENT_ID!, client_secret: process.env.CLIENT_SECRET! }),
});
const data = await res.json();
this.token = data.access_token;
this.expiresAt = Date.now() + data.expires_in * 1000;
return this.token!;
}
}3. A Reusable Fetch Wrapper with Error Handling
export class ApiError extends Error {
constructor(public status: number, public code: string, message: string) {
super(message);
this.name = 'ApiError';
}
}
export async function apiFetch<T>(url: string, options: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
const { timeoutMs = 10_000, ...fetchOptions } = options;
const controller = new AbortController();
const timerId = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { ...fetchOptions, signal: controller.signal });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new ApiError(res.status, body.code ?? 'UNKNOWN_ERROR', body.message ?? res.statusText);
}
return res.json();
} catch (err) {
if ((err as Error).name === 'AbortError') throw new ApiError(408, 'REQUEST_TIMEOUT', `Timed out after ${timeoutMs}ms`);
throw err;
} finally {
clearTimeout(timerId);
}
}4. Retry Logic with Exponential Backoff
const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
export async function fetchWithRetry<T>(url: string, options = {}, maxRetries = 3): Promise<T> {
let attempt = 0;
while (true) {
try {
return await apiFetch<T>(url, options);
} catch (err) {
if (err instanceof ApiError && RETRYABLE_STATUSES.has(err.status) && attempt < maxRetries) {
// Exponential backoff with jitter: 200ms, 400ms, 800ms ± up to 100ms
const delay = 2 ** attempt * 200 + Math.random() * 100;
await new Promise((r) => setTimeout(r, delay));
attempt++;
continue;
}
throw err;
}
}
}5. Caching Strategies
// Next.js App Router — native fetch caching
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 3600, tags: ['products'] },
});
// Redis cache with TTL
export async function getCachedUser(userId: string) {
const cacheKey = `user:${userId}`;
const cached = await redis.get<User>(cacheKey);
if (cached) return cached;
const user = await fetchWithRetry<User>(`https://api.example.com/users/${userId}`);
await redis.setex(cacheKey, 300, JSON.stringify(user));
return user;
}6. Pagination — Cursor vs Offset
// Cursor-based pagination with async generator — preferred for large datasets
async function* fetchAllOrdersCursor() {
let cursor: string | null = null;
do {
const params = new URLSearchParams({ limit: '100' });
if (cursor) params.set('after', cursor);
const page = await fetchWithRetry<{ data: Order[]; nextCursor: string | null }>(`https://api.example.com/orders?${params}`);
yield page.data;
cursor = page.nextCursor;
} while (cursor !== null);
}
for await (const orders of fetchAllOrdersCursor()) {
await processOrders(orders); // process each page without loading all into memory
}7. Webhook Security — Signature Verification
import crypto from 'crypto';
export async function POST(request: Request) {
const rawBody = await request.text();
const signature = request.headers.get('x-signature-sha256') ?? '';
const expected = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET!).update(rawBody, 'utf8').digest('hex');
const sigBuffer = Buffer.from(signature, 'hex');
const expBuffer = Buffer.from(expected, 'hex');
if (sigBuffer.length !== expBuffer.length || !crypto.timingSafeEqual(sigBuffer, expBuffer)) {
return Response.json({ error: 'Invalid signature' }, { status: 401 });
}
const payload = JSON.parse(rawBody);
// process verified payload
return Response.json({ received: true });
}8. Security Hardening Checklist
- Store all API keys and secrets in environment variables — never commit them to version control.
- Make API calls server-side only — route handlers, Server Actions, or backend services. Never expose secret keys to the browser.
- Validate and sanitise all data received from third-party APIs before using it in database queries or rendering it to users.
- Set explicit timeouts on every request to prevent hung connections from blocking your server threads.
- Use HTTPS for every external API call — reject any http:// endpoints.
- Log API errors (status, endpoint, correlation ID) but never log request/response bodies that may contain PII or credentials.
- Rotate API keys periodically and immediately after any suspected exposure.
9. Testing API Integrations with MSW
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.get('https://api.example.com/users/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, name: 'Test User', email: 'test@example.com' });
}),
http.get('https://api.example.com/users/999', () => {
return HttpResponse.json({ code: 'NOT_FOUND', message: 'User not found' }, { status: 404 });
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('throws ApiError on 404', async () => {
await expect(getCachedUser('999')).rejects.toMatchObject({ status: 404 });
});The single most impactful thing you can do for API integration reliability is centralise all fetch calls through one wrapper. You get retry, timeout, error normalisation, logging, and rate-limit handling in one place — and every integration in your codebase benefits automatically.
Need help integrating complex third-party APIs or building a resilient backend data layer? BitPixel Coders builds production-grade API integrations for startups and enterprise teams — get in touch for a free technical review.
Get a Free Consultation