REST API design decisions compound over time. A poorly named resource, inconsistent error format, or absent versioning strategy creates debt that’s expensive to fix once clients exist. Claude Code generates API designs that follow proven conventions — consistent naming, informative errors, and OpenAPI documentation that stays in sync with the code.
This guide covers REST API design with Claude Code: resource naming, status codes, error formats, OpenAPI 3.1, and versioning strategies.
API Design Review
Review this API design and identify inconsistencies and improvements.
Paste in your routes and Claude Code audits them. Common findings:
Inconsistent naming:
# Before — mixed conventions
GET /getAllUsers
GET /user/{id}
POST /createUser
DELETE /users/delete/{id}
# After — consistent resource-based naming
GET /users
GET /users/{id}
POST /users
DELETE /users/{id}
Wrong HTTP methods:
# Before — using POST for everything
POST /users/deactivate/{id}
POST /users/search?q=john
# After — appropriate methods
PATCH /users/{id} # { "status": "inactive" }
GET /users?q=john # Search via query parameters
Missing resource relationships:
# Before — flat structure loses context
GET /orders?userId={id}
# After — nested where appropriate
GET /users/{userId}/orders # User's orders
GET /orders/{id} # Direct access when ID is known
CLAUDE.md for API Projects
## REST API Conventions
- Base URL: /api/v1/
- Resource naming: plural nouns (/users, /orders, /products)
- HTTP methods: GET (read), POST (create), PUT (full replace), PATCH (partial update), DELETE (remove)
- Status codes: 200 (ok), 201 (created), 204 (no content), 400 (bad request), 401 (unauthorized), 403 (forbidden), 404 (not found), 409 (conflict), 422 (validation error), 429 (rate limited), 500 (server error)
- Error format: { "error": "...", "code": "SNAKE_CASE_CODE", "details": {} }
- Pagination: cursor-based for large datasets, page-based for small
- Date format: ISO 8601 (2026-06-17T12:00:00Z)
- All responses: application/json
- API docs: OpenAPI 3.1 in openapi.yaml, generated from code
Consistent Error Responses
Define a standard error format for this API.
Different teams keep returning different structures — unify them.
// src/lib/apiError.ts
interface ApiError {
error: string; // Human-readable message
code: string; // Machine-readable code for client handling
details?: Record<string, string[]>; // Field-level validation errors
requestId?: string; // For support/debugging
}
// Error response factory
export function createErrorResponse(
status: number,
message: string,
code: string,
details?: Record<string, string[]>,
): { status: number; body: ApiError } {
return {
status,
body: {
error: message,
code,
details,
},
};
}
// Named constants prevent typos
export const Errors = {
VALIDATION_FAILED: (details: Record<string, string[]>) =>
createErrorResponse(422, 'Validation failed', 'VALIDATION_FAILED', details),
NOT_FOUND: (resource: string) =>
createErrorResponse(404, `${resource} not found`, 'NOT_FOUND'),
UNAUTHORIZED: () =>
createErrorResponse(401, 'Authentication required', 'UNAUTHORIZED'),
FORBIDDEN: () =>
createErrorResponse(403, 'Access denied', 'FORBIDDEN'),
CONFLICT: (message: string) =>
createErrorResponse(409, message, 'CONFLICT'),
RATE_LIMITED: (retryAfter: number) =>
createErrorResponse(429, `Too many requests. Retry after ${retryAfter}s`, 'RATE_LIMITED'),
INTERNAL: () =>
createErrorResponse(500, 'An unexpected error occurred', 'INTERNAL_ERROR'),
};
Clients can now switch (error.code) to handle specific cases without parsing English strings.
OpenAPI 3.1 Documentation
Generate OpenAPI documentation for the users API.
Include all endpoints, request/response schemas, and error cases.
// src/openapi/users.ts — OpenAPI docs alongside the code
export const usersSpec = {
'/users': {
get: {
summary: 'List users',
operationId: 'listUsers',
tags: ['users'],
security: [{ BearerAuth: [] }],
parameters: [
{
name: 'cursor',
in: 'query',
schema: { type: 'string' },
description: 'Pagination cursor from previous response',
},
{
name: 'limit',
in: 'query',
schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
},
{
name: 'role',
in: 'query',
schema: { type: 'string', enum: ['admin', 'user', 'moderator'] },
},
],
responses: {
200: {
description: 'Paginated list of users',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/User' },
},
nextCursor: { type: 'string', nullable: true },
total: { type: 'integer' },
},
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
},
},
post: {
summary: 'Create user',
operationId: 'createUser',
tags: ['users'],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/CreateUserInput' },
},
},
},
responses: {
201: {
description: 'User created',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/User' },
},
},
},
422: { $ref: '#/components/responses/ValidationError' },
409: { $ref: '#/components/responses/Conflict' },
},
},
},
};
Auto-generate from Code
Auto-generate the OpenAPI spec from the Express routes,
not maintain it separately.
// Using tsoa — decorators generate both routes and OpenAPI spec
import { Controller, Get, Post, Body, Path, Query, Security, Route, Tags } from 'tsoa';
import { User, CreateUserInput, PaginatedUsers } from './types';
@Route('users')
@Tags('users')
export class UsersController extends Controller {
@Get()
@Security('jwt')
async listUsers(
@Query() cursor?: string,
@Query() limit: number = 20,
@Query() role?: 'admin' | 'user' | 'moderator',
): Promise<PaginatedUsers> {
return userService.list({ cursor, limit, role });
}
@Get('{userId}')
@Security('jwt')
async getUser(@Path() userId: string): Promise<User> {
const user = await userService.findById(userId);
if (!user) {
this.setStatus(404);
throw new Error('User not found');
}
return user;
}
}
// tsoa generates openapi.yaml automatically from these decorators
// Run: npx tsoa spec-and-routes
API Versioning
We need to add a breaking change to the /users endpoint.
How do I add versioning without breaking existing clients?
Claude Code implements URL versioning (most explicit) and recommends when each approach fits:
// URL versioning — most explicit, easy to route, easy to document
// /api/v1/users — original
// /api/v2/users — new breaking change
app.use('/api/v1', v1Router); // Maintained for backwards compat
app.use('/api/v2', v2Router); // New version
// Shared business logic, different request/response shapes
v1Router.get('/users', async (req, res) => {
const users = await userService.list(req.query);
res.json(users.map(u => transformUserV1(u))); // V1 shape
});
v2Router.get('/users', async (req, res) => {
const { data, nextCursor } = await userService.listPaginated(req.query);
res.json({ data: data.map(u => transformUserV2(u)), nextCursor }); // V2 shape
});
// Deprecation notice in v1 responses
v1Router.use((req, res, next) => {
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 01 Jan 2027 00:00:00 GMT');
res.set('Link', '</api/v2>; rel="successor-version"');
next();
});
For building the API endpoints with tRPC (type-safe alternative to REST for TypeScript monorepos), see the tRPC guide. For GraphQL as an alternative API design, see the GraphQL guide. For documenting APIs and generating changelogs, see the documentation guide. The Claude Skills 360 bundle includes API design skill sets for REST conventions and OpenAPI generation. Start with the free tier to try API design review and documentation generation.