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.
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.