Back to Blog

API Design Patterns Every Startup Should Follow

API Design Patterns Every Startup Should Follow

A bad API is technical debt that compounds faster than almost anything else in a codebase. Every frontend component, mobile app screen, integration partner, and webhook consumer depends on your API’s contracts. Change them carelessly and everything breaks. Design them poorly and every new feature requires workarounds.

We have designed APIs for over a dozen products, including Trackelio (public-facing feedback boards with voting and webhooks), LancerSpace (freelancer workspace with proposals, invoices, and CRM), and MindHyv (business platform with bookings, payments, and social features). This post covers the patterns we apply to every API we build.

Resource Naming

Your API URLs should describe resources, not actions. Resources are nouns. Actions are HTTP methods.

# Good
GET    /api/v1/feedback          -- list feedback items
POST   /api/v1/feedback          -- create a feedback item
GET    /api/v1/feedback/:id      -- get one feedback item
PATCH  /api/v1/feedback/:id      -- update a feedback item
DELETE /api/v1/feedback/:id      -- delete a feedback item

# Bad
GET    /api/v1/getFeedback
POST   /api/v1/createFeedback
POST   /api/v1/updateFeedback
POST   /api/v1/deleteFeedback

Use plural nouns for collections (/feedback, not /feedbackItem). Nest related resources under their parent when the relationship is strong: /boards/:boardId/feedback for feedback that belongs to a board.

Do not nest more than two levels deep. /organizations/:orgId/projects/:projectId/tasks/:taskId/comments is hard to type, hard to read, and hard to cache. Instead, use top-level resources with query parameters: /comments?taskId=abc.

For actions that do not map cleanly to CRUD operations, use a sub-resource verb: /feedback/:id/vote, /invoices/:id/send. Keep these rare. If you have more action endpoints than resource endpoints, your API is not RESTful — it is an RPC API wearing a REST costume.

Status Codes

Use HTTP status codes correctly. This sounds obvious, but we regularly encounter APIs that return 200 for everything and encode success/failure in the response body.

// Status codes we use consistently

// Success
200 // OK -- GET, PATCH, DELETE that returns data
201 // Created -- POST that creates a resource
204 // No Content -- DELETE that returns nothing

// Client errors
400 // Bad Request -- validation failed, malformed input
401 // Unauthorized -- no valid auth token
403 // Forbidden -- valid token but insufficient permissions
404 // Not Found -- resource does not exist
409 // Conflict -- duplicate entry, state conflict
422 // Unprocessable Entity -- semantically invalid (we prefer 400)
429 // Too Many Requests -- rate limited

// Server errors
500 // Internal Server Error -- unexpected failure
503 // Service Unavailable -- maintenance, overloaded

The distinction between 401 and 403 matters. 401 means “I do not know who you are” (missing or invalid token). 403 means “I know who you are and you are not allowed to do this” (valid token, insufficient permissions). Mixing these up causes auth debugging nightmares.

Error Response Format

Every error response should have the same shape. The client should never have to guess the structure of an error.

// Our standard error response type
interface ApiError {
  error: {
    code: string;        // Machine-readable error code
    message: string;     // Human-readable description
    details?: Record<string, string[]>; // Field-level validation errors
  };
}

In practice:

// 400 -- Validation error
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid input",
    "details": {
      "title": ["Title is required", "Title must be under 200 characters"],
      "category": ["Category must be one of: bug, feature, improvement"]
    }
  }
}

// 404 -- Not found
{
  "error": {
    "code": "NOT_FOUND",
    "message": "Feedback item not found"
  }
}

// 429 -- Rate limited
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Too many requests. Retry after 30 seconds.",
    "details": {
      "retryAfter": ["30"]
    }
  }
}

The code field is for programmatic handling. The message field is for developers reading logs or error screens. The details field is for form validation where you need to map errors to specific fields.

Software engineer writing code for API endpoint implementation

Here is the server-side implementation in TypeScript:

// lib/api-error.ts
export class ApiError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public details?: Record<string, string[]>
  ) {
    super(message);
  }

  toJSON() {
    return {
      error: {
        code: this.code,
        message: this.message,
        ...(this.details && { details: this.details }),
      },
    };
  }
}

// Usage in a request handler
import { z } from "zod";

const createFeedbackSchema = z.object({
  title: z.string().min(1, "Title is required").max(200),
  description: z.string().max(5000).optional(),
  category: z.enum(["bug", "feature", "improvement"]),
  boardId: z.string().uuid("Invalid board ID"),
});

export async function handleCreateFeedback(req: Request) {
  const body = await req.json();
  const result = createFeedbackSchema.safeParse(body);

  if (!result.success) {
    const details: Record<string, string[]> = {};
    for (const issue of result.error.issues) {
      const field = issue.path.join(".");
      if (!details[field]) details[field] = [];
      details[field].push(issue.message);
    }
    throw new ApiError(400, "VALIDATION_ERROR", "Invalid input", details);
  }

  // Proceed with validated data
  const feedback = await createFeedback(result.data);
  return new Response(JSON.stringify(feedback), { status: 201 });
}

We share validation schemas between frontend and backend using the approach described in how we structure monorepos. The same Zod schema validates the form client-side and the request body server-side.

Pagination

Every list endpoint needs pagination. Returning unbounded result sets is a performance and security risk. We have seen APIs that return thousands of rows by default because “we will add pagination later.” Later never comes until production falls over.

There are two approaches: offset-based and cursor-based.

Offset Pagination

Simple, works with any SQL database, easy to understand.

// Request
GET /api/v1/feedback?limit=20&offset=40

// Response
{
  "data": [...],
  "pagination": {
    "total": 156,
    "limit": 20,
    "offset": 40,
    "hasMore": true
  }
}
// Server implementation
export async function listFeedback(boardId: string, limit = 20, offset = 0) {
  const safeLimit = Math.min(Math.max(limit, 1), 100); // Cap at 100

  const { data, count } = await supabase
    .from("feedback")
    .select("*", { count: "exact" })
    .eq("board_id", boardId)
    .order("created_at", { ascending: false })
    .range(offset, offset + safeLimit - 1);

  return {
    data,
    pagination: {
      total: count,
      limit: safeLimit,
      offset,
      hasMore: offset + safeLimit < (count ?? 0),
    },
  };
}

The problem with offset pagination: if rows are inserted or deleted between page fetches, items can be skipped or duplicated. For a feedback board where items are added frequently, this causes visible issues.

Cursor Pagination

Uses a pointer (cursor) to the last item fetched. More resilient to concurrent modifications.

// Request
GET /api/v1/feedback?limit=20&cursor=eyJpZCI6ImFiYzEyMyJ9

// Response
{
  "data": [...],
  "pagination": {
    "limit": 20,
    "nextCursor": "eyJpZCI6InhjdjQ1NiJ9",
    "hasMore": true
  }
}
// Server implementation
export async function listFeedback(
  boardId: string,
  limit = 20,
  cursor?: string
) {
  const safeLimit = Math.min(Math.max(limit, 1), 100);
  const decoded = cursor
    ? JSON.parse(Buffer.from(cursor, "base64url").toString())
    : null;

  let query = supabase
    .from("feedback")
    .select("*")
    .eq("board_id", boardId)
    .order("created_at", { ascending: false })
    .limit(safeLimit + 1); // Fetch one extra to determine hasMore

  if (decoded) {
    query = query.lt("created_at", decoded.createdAt);
  }

  const { data } = await query;
  const hasMore = (data?.length ?? 0) > safeLimit;
  const items = data?.slice(0, safeLimit) ?? [];

  const lastItem = items[items.length - 1];
  const nextCursor = hasMore && lastItem
    ? Buffer.from(
        JSON.stringify({ createdAt: lastItem.created_at })
      ).toString("base64url")
    : null;

  return {
    data: items,
    pagination: {
      limit: safeLimit,
      nextCursor,
      hasMore,
    },
  };
}

We use cursor pagination for any list that updates frequently or is consumed by infinite scroll. We use offset pagination for admin dashboards where users need to jump to a specific page. On Trackelio, the public feedback board uses cursor pagination (items are added constantly), while the admin dashboard uses offset pagination (operators need to jump to page 5 to find a specific item).

Versioning

Version your API from day one. Not because you need multiple versions yet, but because adding versioning later means a breaking change for every existing consumer.

We use URL path versioning: /api/v1/feedback. It is explicit, easy to route, and easy for developers to understand.

// Route structure
// /api/v1/feedback  -- current stable version
// /api/v2/feedback  -- new version (when needed)

The rules for when you need a new version:

  • Removing a field from a response — breaking change, needs a new version.
  • Renaming a field — breaking change, needs a new version.
  • Changing a field’s type — breaking change, needs a new version.
  • Adding a new optional field to a response — not breaking, does not need a new version.
  • Adding a new optional parameter to a request — not breaking, does not need a new version.

In practice, we rarely go past v1 for startup APIs. Most breaking changes can be avoided by adding new fields rather than modifying existing ones, or by creating new endpoints rather than changing existing ones.

When we do need a v2, we keep v1 running for a defined deprecation period (usually 6 months) and communicate the timeline clearly in response headers:

// Deprecation headers
res.setHeader("Deprecation", "true");
res.setHeader("Sunset", "2026-07-01T00:00:00Z");
res.setHeader("Link", '</api/v2/feedback>; rel="successor-version"');

Rate Limiting

Every public API needs rate limiting. Without it, a single misbehaving client can take down your service.

// Simple in-memory rate limiter for edge functions
const rateLimits = new Map<string, { count: number; resetAt: number }>();

function checkRateLimit(
  key: string,
  maxRequests: number,
  windowMs: number
): { allowed: boolean; remaining: number; resetAt: number } {
  const now = Date.now();
  const record = rateLimits.get(key);

  if (!record || now > record.resetAt) {
    const resetAt = now + windowMs;
    rateLimits.set(key, { count: 1, resetAt });
    return { allowed: true, remaining: maxRequests - 1, resetAt };
  }

  if (record.count >= maxRequests) {
    return { allowed: false, remaining: 0, resetAt: record.resetAt };
  }

  record.count += 1;
  return {
    allowed: true,
    remaining: maxRequests - record.count,
    resetAt: record.resetAt,
  };
}

// Usage in a handler
export async function handleRequest(req: Request) {
  const clientIp = req.headers.get("x-forwarded-for") ?? "unknown";
  const { allowed, remaining, resetAt } = checkRateLimit(
    clientIp,
    100,  // 100 requests
    60000 // per minute
  );

  const headers = {
    "X-RateLimit-Limit": "100",
    "X-RateLimit-Remaining": String(remaining),
    "X-RateLimit-Reset": String(Math.ceil(resetAt / 1000)),
  };

  if (!allowed) {
    return new Response(
      JSON.stringify({
        error: {
          code: "RATE_LIMITED",
          message: "Too many requests",
        },
      }),
      { status: 429, headers }
    );
  }

  // Process request normally
  // ...
}

For production systems, use a Redis-backed rate limiter or a service like Cloudflare’s rate limiting. The in-memory approach above works for single-instance edge functions but does not share state across instances.

We set different rate limits for different operations:

  • Read operations (GET): 100-200 requests per minute
  • Write operations (POST, PATCH, DELETE): 20-50 requests per minute
  • Auth operations (login, signup): 5-10 requests per minute
  • Expensive operations (reports, exports): 2-5 requests per minute

Always include rate limit headers in every response, not just 429 responses. This lets clients implement proactive backoff.

Network architecture design showing API gateway and service connections

Authentication Pattern

We use Bearer token authentication with JWTs issued by Supabase Auth. The pattern is simple:

// Middleware to extract and verify the user
async function authenticateRequest(req: Request) {
  const authHeader = req.headers.get("authorization");

  if (!authHeader?.startsWith("Bearer ")) {
    throw new ApiError(401, "UNAUTHORIZED", "Missing authorization header");
  }

  const token = authHeader.slice(7);
  const {
    data: { user },
    error,
  } = await supabase.auth.getUser(token);

  if (error || !user) {
    throw new ApiError(401, "UNAUTHORIZED", "Invalid or expired token");
  }

  return user;
}

For public endpoints that support both authenticated and anonymous access (like a public feedback board that shows different UI for logged-in users), we make authentication optional:

async function optionalAuth(req: Request) {
  try {
    return await authenticateRequest(req);
  } catch {
    return null;
  }
}

Response Envelope

We wrap all responses in a consistent envelope. Not everyone does this — some APIs return raw arrays for list endpoints — but we find the envelope makes the API more predictable and extensible.

// Single resource
{
  "data": {
    "id": "fb_123",
    "title": "Add dark mode",
    "category": "feature",
    "votesCount": 42,
    "createdAt": "2026-01-05T10:30:00Z"
  }
}

// List of resources
{
  "data": [
    { "id": "fb_123", ... },
    { "id": "fb_456", ... }
  ],
  "pagination": {
    "total": 156,
    "limit": 20,
    "offset": 0,
    "hasMore": true
  }
}

// Empty list
{
  "data": [],
  "pagination": {
    "total": 0,
    "limit": 20,
    "offset": 0,
    "hasMore": false
  }
}

The data key is always present. For errors, the error key replaces it. A client never has to check both — if error exists, it is an error. If data exists, it is a success.

Naming Conventions

We use camelCase for JSON fields because our frontends are TypeScript and camelCase is idiomatic JavaScript. Our database uses snake_case because that is idiomatic PostgreSQL. We transform at the API boundary:

// Transform database row to API response
function toApiResponse(row: DatabaseRow): ApiResponse {
  return {
    id: row.id,
    title: row.title,
    votesCount: row.votes_count,
    createdAt: row.created_at,
    boardId: row.board_id,
  };
}

Pick a convention and be ruthless about consistency. A client should never have to guess whether a field is createdAt, created_at, or CreatedAt.

Developer programming on a laptop with code editor visible on screen

Timestamps are always ISO 8601 with timezone: 2026-01-05T10:30:00Z. IDs are always UUIDs or prefixed strings (fb_123, board_abc). Prefixed IDs are nice because you can tell what a resource is from its ID alone, which helps enormously when debugging.

What Comes Next

These patterns form the baseline for every API we build. They are not exhaustive — we have not covered webhooks, file uploads, or batch operations — but they handle the majority of startup API needs.

The best API is one your team can maintain and your consumers can understand without reading documentation for every endpoint. Consistency in naming, error handling, pagination, and response format gets you most of the way there.

For more on how we structure the code that implements these APIs, see our post on structuring monorepos. For the database layer beneath the API, see our database selection guide.

If you need an API designed and built for your product, reach out at hello@threshline.com.