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

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) // SingularTrust 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 = routerThe 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 = routesBy 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 = routerFastify 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 = userRoutesUsing 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 = routerFastify 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 = authRoutesStateless 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.

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 = routerFastify 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 = userRoutesUsing 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 = routesYou 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 = routerFastify'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 = userRoutesMeaningful 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:
- Designing API Methods in JavaScript
- The Power of Caching in JavaScript
- Best Practices to Control Your Errors
Photo Credits:
- Photo by Kevin Ku on Pexels (Data codes through eyeglasses)
- Photo by olia danilevich on Pexels (Programmer working on laptops)
Research Sources: