Backend

Modern API Design Principles: Building APIs Developers Love

FrootsyTech Solutions
7 min read

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

  1. Use resource-based URLs and proper HTTP methods
  2. Return meaningful status codes and consistent errors
  3. Implement versioning from day one
  4. Add rate limiting to prevent abuse
  5. Validate all inputs with schemas
  6. Document thoroughly with OpenAPI/GraphQL introspection
  7. Optimize with caching and compression
  8. Secure with authentication and authorization
  9. Monitor and log all API usage
  10. 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.

Share this article

FrootsyTech Solutions

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.