Skip to main content
Engineering7 min readMarch 3, 2026

Building REST APIs With TypeScript: Patterns From Production

The REST API patterns I use in production TypeScript projects — consistent response shapes, error handling, pagination, versioning, validation, and OpenAPI documentation.

James Ross Jr.

James Ross Jr.

Strategic Systems Architect & Enterprise Software Developer

REST API design is a topic with a lot of strong opinions and surprisingly little consensus on the details. I have designed and maintained enough production APIs to have settled on a set of patterns that I apply consistently. Not because they are the only right way — but because consistency within a codebase is more valuable than perfection on any individual decision.

Here are the patterns.

Response Envelope

Every API response uses the same envelope shape. This makes clients predictable and makes error handling uniform:

// Successful responses
{
  "data": { ... },
  "meta": {
    "timestamp": "2026-03-03T12:00:00Z",
    "requestId": "req_01j...",
    "version": "2.0"
  }
}

// Paginated responses
{
  "data": [ ... ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 147,
    "pages": 8
  },
  "meta": { ... }
}

// Error responses
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "The request body failed validation",
    "details": {
      "email": ["Invalid email address"],
      "name": ["Required field missing"]
    }
  },
  "meta": { ... }
}

Define these shapes as TypeScript types and use them everywhere:

// src/types/response.ts
export interface Meta {
  timestamp: string
  requestId: string
  version: string
}

export interface SuccessResponse<T> {
  data: T
  meta: Meta
}

export interface PaginatedResponse<T> extends SuccessResponse<T[]> {
  pagination: {
    page: number
    limit: number
    total: number
    pages: number
  }
}

export interface ErrorResponse {
  error: {
    code: string
    message: string
    details?: unknown
  }
  meta: Meta
}

Consistent Error Codes

Use string error codes, not just HTTP status codes. Status codes tell you the category of error; error codes tell you specifically what went wrong:

export type ErrorCode =
  | 'VALIDATION_ERROR'
  | 'NOT_FOUND'
  | 'UNAUTHORIZED'
  | 'FORBIDDEN'
  | 'CONFLICT'
  | 'RATE_LIMITED'
  | 'INTERNAL_ERROR'
  | 'SERVICE_UNAVAILABLE'

Clients can switch on error codes to provide specific UX — show a login prompt for UNAUTHORIZED, a retry button for SERVICE_UNAVAILABLE, inline field errors for VALIDATION_ERROR. HTTP status codes alone do not give clients enough information.

Pagination

Cursor-based pagination scales better than offset-based. For large datasets, OFFSET 10000 requires the database to scan and discard 10,000 rows. A cursor-based approach uses an indexed column to jump directly to the right position.

// Request: GET /posts?cursor=post_abc123&limit=20&direction=after

// Response
{
  "data": [...],
  "pagination": {
    "cursor": {
      "before": "post_xyz789",
      "after": "post_def456",
      "hasMore": true
    },
    "limit": 20
  }
}

Implementation with Prisma:

async function getPosts(cursor?: string, limit = 20) {
  const items = await prisma.post.findMany({
    take: limit + 1,  // Fetch one extra to check hasMore
    cursor: cursor ? { id: cursor } : undefined,
    skip: cursor ? 1 : 0,  // Skip the cursor item itself
    orderBy: { createdAt: 'desc' },
  })

  const hasMore = items.length > limit
  const data = hasMore ? items.slice(0, -1) : items

  return {
    data,
    pagination: {
      cursor: {
        before: data[0]?.id,
        after: data[at(-1)]?.id,
        hasMore,
      },
      limit,
    },
  }
}

For simpler use cases where you do not need cursor pagination, offset pagination is fine. Just be aware of the performance implications on large tables.

API Versioning

API versioning prevents breaking changes from breaking existing clients. The strategies are URL versioning, header versioning, and content negotiation.

I use URL versioning (/api/v2/) for public APIs because it is explicit and unambiguous. For internal APIs consumed only by your own frontend, versioning may be unnecessary if you deploy frontend and backend together.

/api/v1/users      ← Original API
/api/v2/users      ← New API with breaking changes

Structure your router to support multiple versions:

// Hono example
const v1 = new Hono()
const v2 = new Hono()

v1.get('/users', usersV1Handler)
v2.get('/users', usersV2Handler)

const api = new Hono()
api.route('/v1', v1)
api.route('/v2', v2)

Maintain old API versions for a published deprecation window (typically 6-12 months), not indefinitely. Announce deprecation clearly, provide migration guides, and actually remove deprecated versions on schedule.

Input Validation Pattern

All inputs are untrusted. Validate everything at the API boundary:

import { z } from 'zod'

// Define schemas close to where they are used
const paginationSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
})

const createUserSchema = z.object({
  name: z.string().trim().min(1).max(100),
  email: z.string().email().toLowerCase(),
  role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),
})

// Types derived from schemas
type PaginationParams = z.infer<typeof paginationSchema>
type CreateUserInput = z.infer<typeof createUserSchema>

// Validation helper
function validateQuery<T>(
  query: unknown,
  schema: z.ZodSchema<T>
): T {
  const result = schema.safeParse(query)
  if (!result.success) {
    throw new ValidationError(result.error.flatten().fieldErrors)
  }
  return result.data
}

Field Filtering and Sparse Fieldsets

Allow clients to request only the fields they need. This reduces payload size and prevents over-fetching:

// GET /users?fields=id,name,email

async function getUser(id: string, fields?: string) {
  const allowedFields = ['id', 'name', 'email', 'role', 'createdAt']
  const requestedFields = fields
    ? fields.split(',').filter(f => allowedFields.includes(f))
    : allowedFields

  const select = Object.fromEntries(
    requestedFields.map(field => [field, true])
  )

  return prisma.user.findUniqueOrThrow({
    where: { id },
    select,
  })
}

Rate Limiting Headers

Return rate limit information in response headers so clients can implement backoff:

function setRateLimitHeaders(
  res: Response,
  limit: number,
  remaining: number,
  resetAt: Date
) {
  res.setHeader('X-RateLimit-Limit', limit)
  res.setHeader('X-RateLimit-Remaining', remaining)
  res.setHeader('X-RateLimit-Reset', Math.ceil(resetAt.getTime() / 1000))
  res.setHeader('Retry-After', Math.ceil((resetAt.getTime() - Date.now()) / 1000))
}

OpenAPI Documentation

Auto-generate OpenAPI documentation from your code rather than maintaining it separately. With Hono, use @hono/zod-openapi:

import { OpenAPIHono, createRoute } from '@hono/zod-openapi'
import { z } from 'zod'

const app = new OpenAPIHono()

const createUserRoute = createRoute({
  method: 'post',
  path: '/users',
  request: {
    body: {
      content: {
        'application/json': {
          schema: createUserSchema,
        },
      },
    },
  },
  responses: {
    201: {
      content: {
        'application/json': {
          schema: UserSchema,
        },
      },
      description: 'User created successfully',
    },
  },
})

app.openapi(createUserRoute, async (c) => {
  const data = c.req.valid('json')
  const user = await createUser(data)
  return c.json(user, 201)
})

// Serve the OpenAPI spec
app.doc('/docs/spec', {
  openapi: '3.0.0',
  info: { title: 'My API', version: '1.0.0' },
})

Documentation that lives in your code stays in sync with your implementation. External documentation always drifts.

Health Checks

Every production API needs health check endpoints:

app.get('/health', (c) => {
  return c.json({ status: 'ok', timestamp: new Date().toISOString() })
})

app.get('/health/ready', async (c) => {
  try {
    // Check database connectivity
    await prisma.$queryRaw`SELECT 1`

    return c.json({
      status: 'ready',
      checks: {
        database: 'ok',
      },
    })
  } catch {
    return c.json({
      status: 'not ready',
      checks: {
        database: 'error',
      },
    }, 503)
  }
})

/health is the liveness check — is the process running? /health/ready is the readiness check — can the process serve traffic? Load balancers and orchestration systems use these endpoints for routing decisions.

Consistent patterns matter more than perfect patterns. A team that follows the same conventions across all services can move faster, debug problems more quickly, and onboard new members more easily than a team with elegant but inconsistent APIs.


Designing a REST API or need a review of an existing one? I am happy to give you an architecture and design review. Book a call: calendly.com/jamesrossjr.


Keep Reading