jsmanifest logojsmanifest

7 Principles That Actually Matter in RESTFul API Design

7 Principles That Actually Matter in RESTFul API Design

Learn 7 essential RESTful API design principles with real Express and Fastify examples that will help your team build better, more maintainable APIs in JavaScript.

While I was reviewing some legacy code at work the other day, I came across several APIs that were difficult to work with. One endpoint was named /getUsers, another was /createUser, and error handling was all over the place. Boy, not everything in life goes in our favor, but good API design can make everyone's day simpler for them.

In today's world of microservices and distributed systems, designing a RESTful API that is both intuitive and maintainable isn't just a nice-to-have—it's essential. I cannot stress this enough! When your API is well-designed, developers can integrate with it faster, make fewer mistakes, and actually enjoy the experience. Working smart is the way to go.

This post will go over 7 RESTful API design principles that focus on developer experience and will help your team build better APIs in JavaScript. We'll look at real code examples from both Express and Fastify to show how these principles work in practice.

1. Use Nouns, Not Verbs in Endpoints

When I first started building APIs, I was guilty of creating endpoints like /getUserById, /createNewUser, and /deleteUserAccount. It seemed logical at the time—the endpoint name tells you exactly what it does, right? Little did I know that this approach creates unnecessarily long URLs and goes against REST principles.

Here's the problem:

// Bad - verb-based endpoints (Express)
router.get('/getUsers', async (req, res) => {
  const users = await db.users.findAll()
  res.json(users)
})
 
router.post('/createUser', async (req, res) => {
  const user = await db.users.create(req.body)
  res.json(user)
})
 
router.delete('/deleteUser/:id', async (req, res) => {
  await db.users.delete(req.params.id)
  res.status(204).send()
})

The HTTP method already tells us what action we're performing. Using verbs in the endpoint creates redundancy and makes URLs unnecessarily long.

In other words, we can simplify this dramatically:

// Better - noun-based endpoints (Express)
const express = require('express')
const router = express.Router()
 
router.get('/users', async (req, res) => {
  const users = await db.users.findAll()
  res.json(users)
})
 
router.post('/users', async (req, res) => {
  const user = await db.users.create(req.body)
  res.status(201).json(user)
})
 
router.delete('/users/:id', async (req, res) => {
  await db.users.delete(req.params.id)
  res.status(204).send()
})

Here's the same pattern in Fastify:

// Fastify implementation
async function userRoutes(fastify, options) {
  // GET /users - List all users
  fastify.get('/users', async (request, reply) => {
    const users = await db.users.findAll()
    return users
  })
 
  // POST /users - Create new user
  fastify.post('/users', async (request, reply) => {
    const user = await db.users.create(request.body)
    reply.code(201)
    return user
  })
 
  // DELETE /users/:id - Delete user
  fastify.delete('/users/:id', async (request, reply) => {
    await db.users.delete(request.params.id)
    reply.code(204).send()
  })
}
 
module.exports = userRoutes

Make no mistake about it—using nouns instead of verbs makes your API predictable and RESTful. Developers integrating with your API will instantly understand the pattern and know how to interact with any resource.

Eyeglasses reflecting computer code on a monitor, ideal for technology and programming themes.

2. Keep URLs Consistent and Plural

I've worked on projects where one endpoint was /user/123 and another was /users. Some endpoints used singular nouns, others used plural. This inconsistency creates confusion and slows down development because developers have to constantly check the documentation to remember which form to use.

Here's an example of what I mean:

// Bad - inconsistent naming (Express)
router.get('/user/:id', getUser) // Singular
router.get('/users', getUsers) // Plural
router.get('/posts', getPosts) // Plural
router.get('/comment/:id', getComment) // Singular

Trust me, when you keep switching between singular and plural forms, it's noticeably different in a negative way. Developers waste mental energy trying to remember which endpoints use which convention.

Luckily we can fix this by always using plural nouns:

// Better - consistent plural naming (Express)
const express = require('express')
const router = express.Router()
 
router.get('/users/:id', getUser)
router.get('/users', getUsers)
router.get('/posts', getPosts)
router.get('/posts/:id', getPost)
router.get('/comments', getComments)
router.get('/comments/:id', getComment)
 
module.exports = router

The Fastify equivalent:

// Fastify - consistent plural naming
async function routes(fastify, options) {
  // Users routes
  fastify.get('/users/:id', getUser)
  fastify.get('/users', getUsers)
 
  // Posts routes
  fastify.get('/posts/:id', getPost)
  fastify.get('/posts', getPosts)
 
  // Comments routes
  fastify.get('/comments/:id', getComment)
  fastify.get('/comments', getComments)
}
 
module.exports = routes

By keeping URLs consistent and always using plurals, you reduce cognitive load. Developers can predict endpoint names without checking documentation every time. It's little things like this that can make everyone's day simpler for them.

3. Leverage HTTP Methods Correctly

Some of us have probably seen APIs where every operation uses POST, or worse, GET requests that modify data. When I finally decided to learn the proper use of HTTP methods, I realized how much easier it made API design and usage.

Here's a problematic pattern I've encountered:

// Bad - misusing HTTP methods (Express)
router.get('/users/delete/:id', async (req, res) => {
  // Using GET for deletion - wrong!
  await db.users.delete(req.params.id)
  res.json({ success: true })
})
 
router.post('/users/fetch', async (req, res) => {
  // Using POST for retrieval - unnecessary!
  const users = await db.users.findAll()
  res.json(users)
})

This is problematic because:

  • GET requests should never modify data (they can be cached and are considered safe)
  • POST should not be used for simple data retrieval
  • Browser prefetching and caching can cause unexpected side effects

In other words, we need to use HTTP methods semantically:

// Better - semantic HTTP methods (Express)
const express = require('express')
const router = express.Router()
 
// GET - Read data (safe, idempotent, cacheable)
router.get('/users', async (req, res) => {
  const users = await db.users.findAll()
  res.json(users)
})
 
router.get('/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id)
  res.json(user)
})
 
// POST - Create new resource (not idempotent)
router.post('/users', async (req, res) => {
  const user = await db.users.create(req.body)
  res.status(201).json(user)
})
 
// PUT - Replace entire resource (idempotent)
router.put('/users/:id', async (req, res) => {
  const user = await db.users.replace(req.params.id, req.body)
  res.json(user)
})
 
// PATCH - Partial update (idempotent)
router.patch('/users/:id', async (req, res) => {
  const user = await db.users.update(req.params.id, req.body)
  res.json(user)
})
 
// DELETE - Remove resource (idempotent)
router.delete('/users/:id', async (req, res) => {
  await db.users.delete(req.params.id)
  res.status(204).send()
})
 
module.exports = router

Fastify makes this even better with schema validation:

// Fastify - semantic methods with validation
async function userRoutes(fastify, options) {
  // GET - Read operations
  fastify.get(
    '/users',
    {
      schema: {
        response: {
          200: {
            type: 'array',
            items: { type: 'object' },
          },
        },
      },
    },
    async (request, reply) => {
      return await db.users.findAll()
    },
  )
 
  // POST - Create
  fastify.post(
    '/users',
    {
      schema: {
        body: {
          type: 'object',
          required: ['email', 'password'],
          properties: {
            email: { type: 'string', format: 'email' },
            password: { type: 'string', minLength: 8 },
          },
        },
        response: {
          201: { type: 'object' },
        },
      },
    },
    async (request, reply) => {
      const user = await db.users.create(request.body)
      reply.code(201)
      return user
    },
  )
 
  // PATCH - Partial update
  fastify.patch(
    '/users/:id',
    {
      schema: {
        params: {
          type: 'object',
          properties: {
            id: { type: 'string' },
          },
        },
        body: {
          type: 'object',
          properties: {
            email: { type: 'string', format: 'email' },
            name: { type: 'string' },
          },
        },
      },
    },
    async (request, reply) => {
      return await db.users.update(request.params.id, request.body)
    },
  )
 
  // DELETE - Remove
  fastify.delete('/users/:id', async (request, reply) => {
    await db.users.delete(request.params.id)
    reply.code(204).send()
  })
}
 
module.exports = userRoutes

Using HTTP methods correctly makes your API self-documenting. Developers know that GET is safe to call repeatedly, DELETE is idempotent, and POST creates new resources. This is a done deal for better developer experience.

4. Design Stateless Requests

When I was learning about API design, I came across many examples that relied on server-side sessions. The server would remember who you were between requests using cookies. While this works for traditional web apps, it creates problems for RESTful APIs that need to scale horizontally.

Here's the anti-pattern:

// Bad - session-based authentication (Express)
const session = require('express-session')
const express = require('express')
const app = express()
 
app.use(session({
  secret: 'keyboard cat',
  resave: false,
  saveUninitialized: false
}))
 
// Login endpoint sets session
app.post('/login', async (req, res) => {
  const user = await authenticate(req.body)
  req.session.userId = user.id  // Server stores state
  res.json({ success: true })
})
 
// Protected route depends on session
app.get('/profile', (req, res) => {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Not authenticated' })
  }
  const user = await db.users.findById(req.session.userId)
  res.json(user)
})

The problem with this approach is that each request depends on server-side state. This makes it difficult to:

  • Scale horizontally (need sticky sessions or shared session store)
  • Cache responses effectively
  • Distribute load across multiple servers

In other words, we should include all context in each request:

// Better - stateless JWT authentication (Express)
const express = require('express')
const jwt = require('jsonwebtoken')
const router = express.Router()
 
const JWT_SECRET = process.env.JWT_SECRET
 
// Login returns JWT token
router.post('/login', async (req, res) => {
  const user = await authenticate(req.body)
 
  // Create JWT with user info
  const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, {
    expiresIn: '24h',
  })
 
  res.json({ token })
})
 
// Middleware validates JWT from header
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization']
  const token = authHeader && authHeader.split(' ')[1]
 
  if (!token) {
    return res.status(401).json({ error: 'Token required' })
  }
 
  jwt.verify(token, JWT_SECRET, (err, user) => {
    if (err) {
      return res.status(403).json({ error: 'Invalid token' })
    }
    req.user = user
    next()
  })
}
 
// Protected route uses token
router.get('/profile', authenticateToken, async (req, res) => {
  const user = await db.users.findById(req.user.userId)
  res.json(user)
})
 
module.exports = router

Fastify has excellent JWT plugin support:

// Fastify - stateless authentication
const fastifyJwt = require('@fastify/jwt')
 
async function authRoutes(fastify, options) {
  // Register JWT plugin
  fastify.register(fastifyJwt, {
    secret: process.env.JWT_SECRET,
  })
 
  // Decorator for protected routes
  fastify.decorate('authenticate', async function (request, reply) {
    try {
      await request.jwtVerify()
    } catch (err) {
      reply.send(err)
    }
  })
 
  // Login endpoint
  fastify.post('/login', async (request, reply) => {
    const user = await authenticate(request.body)
 
    const token = fastify.jwt.sign(
      {
        userId: user.id,
        email: user.email,
      },
      {
        expiresIn: '24h',
      },
    )
 
    return { token }
  })
 
  // Protected route
  fastify.get(
    '/profile',
    {
      onRequest: [fastify.authenticate],
    },
    async (request, reply) => {
      const user = await db.users.findById(request.user.userId)
      return user
    },
  )
}
 
module.exports = authRoutes

Stateless design makes your API scalable and cacheable. Each request contains everything the server needs to process it, allowing you to distribute traffic across any number of servers.

Top view of young programmer working on multiple laptops in a modern office setting.

5. Use HTTP Status Codes Properly

I've seen APIs that return 200 OK for everything, even errors. The actual error information is buried in the response body. This forces developers to always parse the body to determine if the request succeeded, which defeats the purpose of HTTP status codes.

Here's what I mean:

// Bad - always returning 200 (Express)
router.get('/users/:id', async (req, res) => {
  try {
    const user = await db.users.findById(req.params.id)
 
    if (!user) {
      // Wrong! Should use 404 status code
      return res.status(200).json({
        success: false,
        error: 'User not found',
      })
    }
 
    res.status(200).json({
      success: true,
      data: user,
    })
  } catch (error) {
    // Wrong! Should use 500 status code
    res.status(200).json({
      success: false,
      error: error.message,
    })
  }
})

This pattern forces developers to write code like this:

// Client code has to check success flag
const response = await fetch('/users/123')
const data = await response.json()
 
// Can't trust the HTTP status!
if (!data.success) {
  throw new Error(data.error)
}

Luckily we can use HTTP status codes semantically:

// Better - semantic status codes (Express)
const express = require('express')
const router = express.Router()
 
router.get('/users/:id', async (req, res) => {
  try {
    const user = await db.users.findById(req.params.id)
 
    if (!user) {
      return res.status(404).json({
        error: 'User not found',
        code: 'USER_NOT_FOUND',
      })
    }
 
    res.status(200).json(user)
  } catch (error) {
    console.error('Database error:', error)
    res.status(500).json({
      error: 'Internal server error',
      code: 'INTERNAL_ERROR',
    })
  }
})
 
router.post('/users', async (req, res) => {
  // Validation error
  if (!req.body.email) {
    return res.status(400).json({
      error: 'Email is required',
      code: 'VALIDATION_ERROR',
    })
  }
 
  try {
    const user = await db.users.create(req.body)
    // 201 for successful creation
    res.status(201).json(user)
  } catch (error) {
    if (error.code === 'DUPLICATE_EMAIL') {
      return res.status(409).json({
        error: 'Email already exists',
        code: 'DUPLICATE_RESOURCE',
      })
    }
    res.status(500).json({
      error: 'Internal server error',
      code: 'INTERNAL_ERROR',
    })
  }
})
 
module.exports = router

Fastify has built-in error handling that makes this even cleaner:

// Fastify - error handling with status codes
async function userRoutes(fastify, options) {
  // Custom error handler
  fastify.setErrorHandler(async (error, request, reply) => {
    // Log error
    fastify.log.error(error)
 
    // Validation error
    if (error.validation) {
      return reply.status(400).send({
        error: 'Validation failed',
        code: 'VALIDATION_ERROR',
        details: error.validation,
      })
    }
 
    // Custom errors
    if (error.statusCode) {
      return reply.status(error.statusCode).send({
        error: error.message,
        code: error.code,
      })
    }
 
    // Default to 500
    return reply.status(500).send({
      error: 'Internal server error',
      code: 'INTERNAL_ERROR',
    })
  })
 
  fastify.get('/users/:id', async (request, reply) => {
    const user = await db.users.findById(request.params.id)
 
    if (!user) {
      reply.code(404)
      throw new Error('User not found')
    }
 
    return user
  })
 
  fastify.post('/users', async (request, reply) => {
    const user = await db.users.create(request.body)
    reply.code(201)
    return user
  })
}
 
module.exports = userRoutes

Using proper HTTP status codes means developers can check response.ok or handle errors based on status ranges (4xx for client errors, 5xx for server errors) without parsing the body. This is what good developer experience looks like.

6. Version Your API from Day One

Recently, there was a colleague that maintained an API that I had to take over since he was no longer maintaining it. The API had no versioning, and when we needed to make breaking changes, we had to coordinate with every single client to update simultaneously. It was a nightmare.

Here's the problem:

// Bad - no versioning (Express)
const express = require('express')
const app = express()
 
// Original endpoint
app.get('/users', (req, res) => {
  // Returns array of users
  const users = db.users.findAll()
  res.json(users)
})
 
// Later, you want to add pagination...
// But this breaks existing clients!
app.get('/users', (req, res) => {
  const page = parseInt(req.query.page) || 1
  const limit = parseInt(req.query.limit) || 10
 
  const users = db.users.findAll({ page, limit })
  const total = db.users.count()
 
  // New response format - BREAKING CHANGE
  res.json({
    data: users,
    pagination: {
      page,
      limit,
      total,
    },
  })
})

When you change the response format without versioning, you break every existing client. In a perfect world all code should be easy to maintain, but versioning helps you evolve your API safely.

Here's how to do it right:

// Better - versioned API (Express)
const express = require('express')
const app = express()
 
// V1 - Original implementation
const v1Router = express.Router()
v1Router.get('/users', (req, res) => {
  const users = db.users.findAll()
  res.json(users)
})
app.use('/v1', v1Router)
 
// V2 - New implementation with pagination
const v2Router = express.Router()
v2Router.get('/users', (req, res) => {
  const page = parseInt(req.query.page) || 1
  const limit = parseInt(req.query.limit) || 10
 
  const users = db.users.findAll({ page, limit })
  const total = db.users.count()
 
  res.json({
    data: users,
    pagination: { page, limit, total },
  })
})
app.use('/v2', v2Router)
 
// Optional: Default to latest version
app.use('/', v2Router)

Fastify makes version management even more elegant:

// Fastify - API versioning
async function v1Routes(fastify, options) {
  fastify.get('/users', async (request, reply) => {
    const users = await db.users.findAll()
    return users
  })
}
 
async function v2Routes(fastify, options) {
  fastify.get(
    '/users',
    {
      schema: {
        querystring: {
          type: 'object',
          properties: {
            page: { type: 'integer', minimum: 1, default: 1 },
            limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
          },
        },
      },
    },
    async (request, reply) => {
      const { page, limit } = request.query
      const users = await db.users.findAll({ page, limit })
      const total = await db.users.count()
 
      return {
        data: users,
        pagination: { page, limit, total },
      }
    },
  )
}
 
// Register versioned routes
async function routes(fastify, options) {
  fastify.register(v1Routes, { prefix: '/v1' })
  fastify.register(v2Routes, { prefix: '/v2' })
 
  // Optional: Version via Accept header
  fastify.addContentTypeParser(
    'application/vnd.api.v1+json',
    { parseAs: 'string' },
    (req, body, done) => done(null, body),
  )
}
 
module.exports = routes

You can also use header-based versioning:

// Header-based versioning (Express)
const express = require('express')
const app = express()
 
function getApiVersion(req) {
  const acceptHeader = req.get('Accept')
  if (acceptHeader && acceptHeader.includes('version=2')) {
    return 'v2'
  }
  return 'v1'
}
 
app.get('/users', (req, res) => {
  const version = getApiVersion(req)
 
  if (version === 'v2') {
    // V2 implementation
    const page = parseInt(req.query.page) || 1
    const limit = parseInt(req.query.limit) || 10
    const users = db.users.findAll({ page, limit })
    const total = db.users.count()
    return res.json({ data: users, pagination: { page, limit, total } })
  }
 
  // V1 implementation
  const users = db.users.findAll()
  res.json(users)
})

Versioning from day one allows you to evolve your API without breaking existing integrations. Your future self (and your clients) will thank you.

7. Return Meaningful Error Messages

When I look back into my beginning stages I was guilty of returning generic error messages like "Error occurred" or "Something went wrong". These messages don't help developers debug issues—they just create frustration.

Here's the anti-pattern:

// Bad - generic error messages (Express)
router.post('/users', async (req, res) => {
  try {
    const user = await db.users.create(req.body)
    res.json(user)
  } catch (error) {
    // Unhelpful error message
    res.status(500).json({
      error: 'Something went wrong',
    })
  }
})
 
router.get('/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id)
  if (!user) {
    // No context about what failed
    return res.status(404).json({
      error: 'Not found',
    })
  }
  res.json(user)
})

These errors don't tell developers:

  • What went wrong
  • Why it failed
  • How to fix it
  • A unique error code for logging/tracking

In other words, we need structured, meaningful error responses:

// Better - meaningful error messages (Express)
const express = require('express')
const router = express.Router()
 
// Error response structure
function createErrorResponse(code, message, details = {}) {
  return {
    error: {
      code,
      message,
      details,
      timestamp: new Date().toISOString(),
    },
  }
}
 
router.post('/users', async (req, res) => {
  try {
    // Validate input
    if (!req.body.email) {
      return res.status(400).json(
        createErrorResponse('VALIDATION_ERROR', 'Email is required', {
          field: 'email',
          reason: 'missing_required_field',
        }),
      )
    }
 
    if (!isValidEmail(req.body.email)) {
      return res.status(400).json(
        createErrorResponse('VALIDATION_ERROR', 'Invalid email format', {
          field: 'email',
          reason: 'invalid_format',
          example: 'user@example.com',
        }),
      )
    }
 
    const user = await db.users.create(req.body)
    res.status(201).json(user)
  } catch (error) {
    console.error('User creation failed:', error)
 
    if (error.code === 'DUPLICATE_KEY') {
      return res.status(409).json(
        createErrorResponse(
          'DUPLICATE_RESOURCE',
          'A user with this email already exists',
          {
            field: 'email',
            value: req.body.email,
          },
        ),
      )
    }
 
    res.status(500).json(
      createErrorResponse('INTERNAL_ERROR', 'Failed to create user', {
        requestId: req.id,
      }),
    )
  }
})
 
router.get('/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id)
 
  if (!user) {
    return res.status(404).json(
      createErrorResponse(
        'RESOURCE_NOT_FOUND',
        `User with ID ${req.params.id} not found`,
        {
          resource: 'user',
          id: req.params.id,
        },
      ),
    )
  }
 
  res.json(user)
})
 
module.exports = router

Fastify's error handling makes this even cleaner:

// Fastify - structured error responses
const createError = require('@fastify/error')
 
// Custom error classes
const ValidationError = createError(
  'VALIDATION_ERROR',
  'Validation failed: %s',
  400,
)
const NotFoundError = createError(
  'RESOURCE_NOT_FOUND',
  'Resource not found: %s',
  404,
)
const DuplicateError = createError(
  'DUPLICATE_RESOURCE',
  'Resource already exists: %s',
  409,
)
 
async function userRoutes(fastify, options) {
  // Global error handler
  fastify.setErrorHandler(async (error, request, reply) => {
    const response = {
      error: {
        code: error.code || 'INTERNAL_ERROR',
        message: error.message,
        timestamp: new Date().toISOString(),
      },
    }
 
    // Add details for custom errors
    if (error.details) {
      response.error.details = error.details
    }
 
    // Add request ID for tracking
    if (request.id) {
      response.error.requestId = request.id
    }
 
    // Log server errors
    if (error.statusCode >= 500) {
      fastify.log.error(error)
    }
 
    return reply.status(error.statusCode || 500).send(response)
  })
 
  fastify.post(
    '/users',
    {
      schema: {
        body: {
          type: 'object',
          required: ['email', 'password'],
          properties: {
            email: { type: 'string', format: 'email' },
            password: { type: 'string', minLength: 8 },
          },
        },
      },
    },
    async (request, reply) => {
      try {
        const user = await db.users.create(request.body)
        reply.code(201)
        return user
      } catch (error) {
        if (error.code === 'DUPLICATE_KEY') {
          throw new DuplicateError('email', {
            details: {
              field: 'email',
              value: request.body.email,
            },
          })
        }
        throw error
      }
    },
  )
 
  fastify.get('/users/:id', async (request, reply) => {
    const user = await db.users.findById(request.params.id)
 
    if (!user) {
      throw new NotFoundError(`User ${request.params.id}`, {
        details: {
          resource: 'user',
          id: request.params.id,
        },
      })
    }
 
    return user
  })
}
 
module.exports = userRoutes

Meaningful error messages help developers:

  • Debug issues faster
  • Understand what went wrong without reading code
  • Track errors in logs with unique codes
  • Provide better error messages to end users

The point is that good error messages are part of good developer experience. Your API consumers will appreciate the clarity.

Conclusion

And that concludes the end of this post! I hope you found these 7 RESTful API design principles valuable and look out for more in the future!

Remember, designing a great API is about treating it as a product. Focus on developer experience: make it intuitive, consistent, and well-documented. Start by implementing one principle at a time—you don't have to refactor everything at once.

If you're building APIs in JavaScript, both Express and Fastify provide excellent tools to implement these patterns. Express offers flexibility and a vast ecosystem, while Fastify brings performance and built-in schema validation. Choose based on your team's needs and familiarity.

The key takeaway? Working smart is the way to go. A well-designed API saves your team time, reduces bugs, and makes integration a pleasure instead of a pain.

Continue Learning:

Photo Credits:

Research Sources: