Modern API Design Principles: Building APIs Developers Love
Quick Answer
Well-designed APIs follow consistent naming conventions, use appropriate HTTP methods and status codes, implement proper error handling, provide comprehensive documentation, and prioritize security and performance. Key principles include resource-based URLs, statelessness, proper versioning, and developer-friendly error messages.
Introduction
A well-designed API is a joy to use and can become a competitive advantage. A poorly designed API frustrates developers and leads to integration problems. Let's explore the principles that separate great APIs from mediocre ones.
REST API Best Practices
Resource-Based URLs
Design URLs around resources, not actions:
# ✗ Bad - action-based URLs
POST /createUser
GET /getUser/123
POST /updateUser/123
DELETE /removeUser/123
# ✓ Good - resource-based URLs
POST /users
GET /users/123
PUT /users/123
DELETE /users/123
Proper HTTP Methods
Use HTTP verbs correctly:
- GET - Retrieve resources (idempotent, safe)
- POST - Create new resources
- PUT - Update entire resources (idempotent)
- PATCH - Partial updates
- DELETE - Remove resources (idempotent)
// User resource API
app.get('/users/:id', async (req, res) => {
const user = await db.users.findUnique({ where: { id: req.params.id } })
if (!user) return res.status(404).json({ error: 'User not found' })
res.json(user)
})
app.post('/users', async (req, res) => {
const user = await db.users.create({ data: req.body })
res.status(201).json(user)
})
app.put('/users/:id', async (req, res) => {
const user = await db.users.update({
where: { id: req.params.id },
data: req.body
})
res.json(user)
})
app.delete('/users/:id', async (req, res) => {
await db.users.delete({ where: { id: req.params.id } })
res.status(204).send()
})
Meaningful Status Codes
Use appropriate HTTP status codes:
// Success codes
200 // OK - Request succeeded
201 // Created - Resource created
204 // No Content - Success with no response body
// Client error codes
400 // Bad Request - Invalid data
401 // Unauthorized - Missing/invalid auth
403 // Forbidden - Valid auth, insufficient permissions
404 // Not Found - Resource doesn't exist
409 // Conflict - Resource state conflict
422 // Unprocessable Entity - Validation errors
// Server error codes
500 // Internal Server Error
503 // Service Unavailable
Consistent Error Responses
Standardize error format:
interface APIError {
error: {
code: string
message: string
details?: Record<string, string[]>
requestId?: string
}
}
// Example error response
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input data",
"details": {
"email": ["Must be a valid email address"],
"age": ["Must be at least 18"]
},
"requestId": "req_abc123"
}
}
Pagination & Filtering
Cursor-Based Pagination
Better for large datasets:
interface PaginatedResponse<T> {
data: T[]
pagination: {
nextCursor?: string
prevCursor?: string
hasMore: boolean
}
}
app.get('/posts', async (req, res) => {
const { cursor, limit = 20 } = req.query
const posts = await db.posts.findMany({
take: limit + 1,
...(cursor && { cursor: { id: cursor }, skip: 1 }),
orderBy: { createdAt: 'desc' }
})
const hasMore = posts.length > limit
const data = hasMore ? posts.slice(0, -1) : posts
res.json({
data,
pagination: {
nextCursor: hasMore ? data[data.length - 1].id : undefined,
hasMore
}
})
})
Filtering & Sorting
Allow flexible queries:
// GET /users?role=admin&status=active&sort=-createdAt&fields=id,name,email
app.get('/users', async (req, res) => {
const { role, status, sort, fields } = req.query
const where: any = {}
if (role) where.role = role
if (status) where.status = status
const orderBy = sort?.startsWith('-')
? { [sort.slice(1)]: 'desc' }
: { [sort]: 'asc' }
const select = fields
? fields.split(',').reduce((acc, field) => ({ ...acc, [field]: true }), {})
: undefined
const users = await db.users.findMany({
where,
orderBy,
select
})
res.json(users)
})
API Versioning
URL Versioning (Recommended)
// Version in URL path
app.use('/v1', v1Router)
app.use('/v2', v2Router)
// Example
GET /v1/users
GET /v2/users
Header Versioning
app.use((req, res, next) => {
const version = req.headers['api-version'] || 'v1'
req.apiVersion = version
next()
})
app.get('/users', (req, res) => {
if (req.apiVersion === 'v2') {
return res.json(getUsersV2())
}
res.json(getUsersV1())
})
Rate Limiting
Protect your API from abuse:
import rateLimit from 'express-rate-limit'
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: 'Too many requests, please try again later',
standardHeaders: true,
legacyHeaders: false,
})
app.use('/api', limiter)
// Tiered rate limiting
const tierLimits = {
free: 100,
pro: 1000,
enterprise: 10000
}
const tierLimiter = async (req, res, next) => {
const user = await getUser(req)
const limit = tierLimits[user.tier]
const used = await redis.incr(`ratelimit:${user.id}`)
if (used > limit) {
return res.status(429).json({
error: 'Rate limit exceeded',
limit,
reset: await redis.ttl(`ratelimit:${user.id}`)
})
}
res.setHeader('X-RateLimit-Limit', limit)
res.setHeader('X-RateLimit-Remaining', limit - used)
next()
}
Authentication & Authorization
JWT Authentication
import jwt from 'jsonwebtoken'
// Generate token
function generateToken(user: User) {
return jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET!,
{ expiresIn: '7d' }
)
}
// Verify middleware
function authenticate(req, res, next) {
const token = req.headers.authorization?.split(' ')[1]
if (!token) {
return res.status(401).json({ error: 'Missing token' })
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!)
req.user = decoded
next()
} catch (error) {
res.status(401).json({ error: 'Invalid token' })
}
}
// Role-based access
function authorize(...roles: string[]) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' })
}
next()
}
}
// Usage
app.get('/admin/users',
authenticate,
authorize('admin', 'superadmin'),
getUsers
)
Request Validation
Use schema validation libraries:
import { z } from 'zod'
const CreateUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(18).max(120),
role: z.enum(['user', 'admin'])
})
function validate(schema: z.ZodSchema) {
return (req, res, next) => {
try {
req.body = schema.parse(req.body)
next()
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(422).json({
error: 'Validation failed',
details: error.errors
})
}
next(error)
}
}
}
app.post('/users', validate(CreateUserSchema), createUser)
GraphQL Considerations
When REST isn't enough:
import { ApolloServer } from '@apollo/server'
import { startServerAndCreateNextHandler } from '@as-integrations/next'
const typeDefs = `
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
}
type Query {
user(id: ID!): User
posts(limit: Int, offset: Int): [Post!]!
}
type Mutation {
createPost(title: String!, content: String!): Post!
}
`
const resolvers = {
Query: {
user: async (_, { id }, context) => {
return context.db.users.findUnique({ where: { id } })
},
posts: async (_, { limit, offset }, context) => {
return context.db.posts.findMany({ take: limit, skip: offset })
}
},
User: {
posts: async (parent, _, context) => {
return context.db.posts.findMany({ where: { authorId: parent.id } })
}
}
}
const server = new ApolloServer({ typeDefs, resolvers })
When to Use GraphQL vs REST
Use REST when:
- Simple CRUD operations
- Cacheable responses
- Public APIs
- Stable, well-defined resources
Use GraphQL when:
- Complex, nested data requirements
- Multiple client types (web, mobile, etc.)
- Frequent schema changes
- Need for precise data fetching
API Documentation
OpenAPI/Swagger
/**
* @swagger
* /users/{id}:
* get:
* summary: Get a user by ID
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: User found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* 404:
* description: User not found
*/
app.get('/users/:id', getUser)
Performance Optimization
Caching Headers
app.get('/posts/:id', (req, res) => {
const post = getPost(req.params.id)
// Cache for 1 hour
res.set('Cache-Control', 'public, max-age=3600')
// ETag for conditional requests
res.set('ETag', generateETag(post))
// Check if client has cached version
if (req.headers['if-none-match'] === res.get('ETag')) {
return res.status(304).send()
}
res.json(post)
})
Compression
import compression from 'compression'
app.use(compression({
filter: (req, res) => {
if (req.headers['x-no-compression']) return false
return compression.filter(req, res)
}
}))
Key Takeaways
- Use resource-based URLs and proper HTTP methods
- Return meaningful status codes and consistent errors
- Implement versioning from day one
- Add rate limiting to prevent abuse
- Validate all inputs with schemas
- Document thoroughly with OpenAPI/GraphQL introspection
- Optimize with caching and compression
- Secure with authentication and authorization
- Monitor and log all API usage
- Version your API to avoid breaking changes
A well-designed API is:
- Intuitive - Easy to understand and use
- Consistent - Follows predictable patterns
- Documented - Comprehensive, up-to-date docs
- Secure - Protected against common vulnerabilities
- Performant - Fast and efficient
- Maintainable - Easy to evolve and extend
Need help designing your API? Get in touch.
FrootsyTech Solutions
Expert Software Development Team
Enterprise Software Development, Cloud Architecture, Full-Stack Engineering
FrootsyTech Solutions is an agile, expert-led software development agency specializing in web and mobile applications. Our team brings decades of combined experience in building scalable, production-ready solutions for businesses worldwide.