API Design Developer Experience

Building Developer APIs
That Don't Suck

Versioning, documentation, error messages, and patterns that make developers actually want to use your API.

๐Ÿ“– 14 min read January 24, 2026

Most APIs are frustrating to use. Cryptic error messages. Inconsistent naming. Documentation that's always out of date. Breaking changes without warning.

Here's how we build APIs that developers actually enjoy integrating with.

Pattern 1: Consistent Error Responses

โŒ Bad Error
{ "error": "Invalid request" }
โœ“ Good Error
{ "error": { "code": "VALIDATION_ERROR", "message": "Email format invalid", "field": "email", "docs": "https://api.example.com/docs/errors#VALIDATION_ERROR" } }
error-handler.ts
interface APIError { code: string; message: string; field?: string; details?: Record<string, any>; } const ERROR_MESSAGES: Record<string, string> = { VALIDATION_ERROR: 'Request validation failed', NOT_FOUND: 'Resource not found', UNAUTHORIZED: 'Authentication required', FORBIDDEN: 'Insufficient permissions', RATE_LIMITED: 'Too many requests', INTERNAL_ERROR: 'An unexpected error occurred' }; function createErrorResponse( code: string, status: number, options?: { message?: string; field?: string; details?: any } ): Response { const error: APIError = { code, message: options?.message || ERROR_MESSAGES[code] || 'Unknown error', field: options?.field, details: options?.details }; return new Response(JSON.stringify({ error, docs: `https://api.proptechusa.ai/docs/errors#${code}` }), { status, headers: { 'Content-Type': 'application/json' } }); }

Pattern 2: Versioning Strategy

version-router.ts
// URL versioning for major versions // api.example.com/v1/leads // api.example.com/v2/leads const router = new Router(); // Version 1 routes (deprecated) router.all('/v1/*', deprecationWarning('2026-06-01'), v1Handler); // Version 2 routes (current) router.all('/v2/*', v2Handler); // Redirect unversioned to latest router.all('/*', (req) => { const url = new URL(req.url); url.pathname = `/v2${url.pathname}`; return Response.redirect(url.toString(), 307); }); function deprecationWarning(sunsetDate: string) { return (handler: Handler): Handler => { return async (request, env, ctx) => { const response = await handler(request, env, ctx); // Add deprecation headers response.headers.set('Deprecation', 'true'); response.headers.set('Sunset', sunsetDate); response.headers.set( 'Link', '<https://api.example.com/v2>; rel="successor-version"' ); return response; }; }; }

Pattern 3: Request Validation

validation.ts
import { z } from 'zod'; const CreateLeadSchema = z.object({ name: z.string().min(1).max(100), email: z.string().email(), phone: z.string().regex(/^\+?[1-9]\d{1,14}$/).optional(), source: z.enum(['website', 'referral', 'ad', 'other']), message: z.string().max(1000).optional() }); function validateRequest<T>( schema: z.ZodSchema<T> ) { return (handler: Handler): Handler => { return async (request, env, ctx) => { let body: unknown; try { body = await request.json(); } catch { return createErrorResponse('INVALID_JSON', 400, { message: 'Request body must be valid JSON' }); } const result = schema.safeParse(body); if (!result.success) { const firstError = result.error.errors[0]; return createErrorResponse('VALIDATION_ERROR', 400, { message: firstError.message, field: firstError.path.join('.'), details: { errors: result.error.errors } }); } request.validated = result.data; return handler(request, env, ctx); }; }; }
Documentation Tip
Show the bad response before the good one. Developers learn faster from contrasts. Include working code examples in JavaScript, Python, and curlโ€”the three languages every developer can read.

Pattern 4: Pagination

pagination.ts
// Cursor-based pagination (better than offset) interface PaginatedResponse<T> { data: T[]; pagination: { cursor: string | null; hasMore: boolean; total?: number; }; } async function paginatedQuery<T>( db: D1Database, table: string, cursor: string | null, limit: number = 20 ): Promise<PaginatedResponse<T>> { const query = cursor ? `SELECT * FROM ${table} WHERE id > ? ORDER BY id LIMIT ?` : `SELECT * FROM ${table} ORDER BY id LIMIT ?`; const params = cursor ? [cursor, limit + 1] : [limit + 1]; const results = await db.prepare(query).bind(...params).all(); const hasMore = results.results.length > limit; const data = hasMore ? results.results.slice(0, -1) : results.results; return { data: data as T[], pagination: { cursor: hasMore ? data[data.length - 1].id : null, hasMore } }; }

API Design Checklist

  • Use consistent error response format across all endpoints
  • Include error codes, messages, affected fields, and doc links
  • Version your API in the URL path (/v1/, /v2/)
  • Add Deprecation and Sunset headers to old versions
  • Validate all requests with clear, specific error messages
  • Use cursor-based pagination (not offset)
  • Return consistent response envelope (data, pagination, meta)
  • Document every endpoint with working examples

The best APIs feel obvious. If developers need to read documentation for basic operations, you've already failed. Design for discoverability first, edge cases second.

Related Articles

API Gateway Patterns
Read more โ†’
Rate Limiting Patterns
Read more โ†’
Error Handling Patterns
Read more โ†’

Need Better APIs?

We build APIs developers love to use.

โ†’ Get Started