Web Development
January 10, 2024
9 min read
3 views

API Integration Best Practices for Modern Web Applications

A developer-focused deep dive into API integration — authentication patterns, typed clients, error handling, retry logic, caching strategies, rate-limit handling, pagination, webhooks, and security hardening, with real TypeScript examples throughout.

API Integration Best PracticesREST API TypeScriptAPI Error HandlingAPI Rate LimitingAPI AuthenticationAPI Caching StrategiesWebhook SecurityAPI PaginationOpenAPI TypeScriptFetch Retry Logic
API Integration Best Practices for Modern Web Applications

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

bash
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

typescript
// 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

typescript
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

typescript
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

typescript
// 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

typescript
// 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

typescript
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

typescript
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