REST API Design with Express: Best Practices

REST API Design with Express: Best Practices
Building a web server is easy. Building a well-designed API that frontend developers love to consume, that is consistent across all endpoints, and that you can evolve without breaking clients — that takes deliberate design.
This module covers the conventions and patterns that senior engineers apply to every API they ship: resource naming, HTTP method semantics, status codes, consistent response shapes, filtering, sorting, pagination, and versioning. These are not opinions — they are the standards used by Stripe, GitHub, Twilio, and every well-regarded public API.
This is Module 10 of the Node.js Full‑Stack Developer course.
The Six Constraints of REST
REST (Representational State Transfer) is not a protocol — it is an architectural style with six constraints:
- Client-Server — the UI and data layer are separated
- Stateless — every request contains all information needed to process it; no session state on the server
- Cacheable — responses must define whether they can be cached
- Uniform Interface — consistent resource identification, self-descriptive messages
- Layered System — clients cannot tell if they are talking directly to a server or a proxy
- Code on Demand (optional) — server can send executable code to clients
The most impactful in practice are stateless and uniform interface. Every design decision in this module flows from them.
Resource Naming Conventions
The URL is the identity of your resource. Get naming right from day one.
Use Nouns, Not Verbs
❌ GET /getUsers
❌ POST /createUser
❌ PUT /updateUser/42
❌ GET /deleteUser/42
✅ GET /users
✅ POST /users
✅ PUT /users/42
✅ DELETE /users/42The HTTP method is the verb. The URL is the noun. Never duplicate the verb in the path.
Use Plural Nouns for Collections
✅ /users (collection)
✅ /users/42 (single resource)
✅ /products
✅ /products/abc-123
✅ /orders
✅ /orders/ORD-9981Use Lowercase and Hyphens
✅ /blog-posts
✅ /product-categories
✅ /shipping-addresses
❌ /blogPosts (camelCase)
❌ /blog_posts (underscore)
❌ /BlogPosts (PascalCase)Expressing Relationships with Nesting
Nest resources when one resource belongs to another:
GET /users/42/posts — all posts by user 42
POST /users/42/posts — create a post for user 42
GET /users/42/posts/7 — specific post by user 42
GET /posts/7/comments — comments on post 7
POST /posts/7/comments — add a comment to post 7Do not nest deeper than two levels. /users/42/posts/7/comments/3/likes is a sign the resource should be a top-level route (/likes/99) with a query filter instead.
HTTP Methods and Their Semantics
| Method | Action | Idempotent | Safe | Body |
|---|---|---|---|---|
GET | Retrieve | ✅ | ✅ | No |
POST | Create | ❌ | ❌ | Yes |
PUT | Replace (full update) | ✅ | ❌ | Yes |
PATCH | Partial update | ❌ | ❌ | Yes |
DELETE | Remove | ✅ | ❌ | No |
HEAD | Like GET but no body | ✅ | ✅ | No |
OPTIONS | Describe allowed methods | ✅ | ✅ | No |
- Idempotent — calling it multiple times has the same effect as calling it once
- Safe — does not modify data
// Full CRUD mapped to HTTP methods
router.get('/', listUsers) // GET /users
router.get('/:id', getUser) // GET /users/42
router.post('/', createUser) // POST /users
router.put('/:id', replaceUser) // PUT /users/42
router.patch('/:id', updateUser) // PATCH /users/42
router.delete('/:id', deleteUser) // DELETE /users/42Consistent Response Shape
The single biggest complaint frontend developers have about APIs is inconsistency — some routes return { user: {...} }, others return just {...}, others return { data: {...} }.
Pick one shape and use it everywhere:
Success Response
{
"success": true,
"data": {
"id": 42,
"name": "Alice",
"email": "alice@example.com"
},
"meta": null
}Collection Response (with pagination)
{
"success": true,
"data": [
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" }
],
"meta": {
"total": 84,
"page": 1,
"limit": 20,
"hasNext": true,
"hasPrev": false
}
}Error Response
{
"success": false,
"data": null,
"error": {
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"details": [
{ "field": "email", "message": "Must be a valid email address" },
{ "field": "name", "message": "Name is required" }
]
}
}Response Helper in Express
Create a response helper to enforce this shape across all routes:
// lib/response.js
function success(res, data, statusCode = 200, meta = null) {
return res.status(statusCode).json({ success: true, data, meta })
}
function error(res, message, statusCode = 500, code = 'INTERNAL_ERROR', details = []) {
return res.status(statusCode).json({
success: false,
data: null,
error: { code, message, details },
})
}
module.exports = { success, error }// In a route handler
const { success, error } = require('../lib/response')
app.get('/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id)
if (!user) return error(res, 'User not found', 404, 'NOT_FOUND')
return success(res, user)
} catch (err) {
next(err)
}
})Filtering, Sorting, and Searching
Pass these as query parameters — never in the URL path:
GET /products?category=electronics&minPrice=100&maxPrice=500
GET /users?role=admin&status=active
GET /posts?search=nodejs&tag=tutorial
GET /orders?status=pending&createdAfter=2026-01-01Implementing Filters in Express
app.get('/products', async (req, res, next) => {
try {
const {
category,
minPrice,
maxPrice,
search,
sort = 'createdAt',
order = 'desc',
page = 1,
limit = 20,
} = req.query
// Build filter object
const filter = {}
if (category) filter.category = category
if (minPrice || maxPrice) {
filter.price = {}
if (minPrice) filter.price.$gte = Number(minPrice)
if (maxPrice) filter.price.$lte = Number(maxPrice)
}
if (search) filter.$text = { $search: search }
// Validate sort field (whitelist to prevent injection)
const allowedSortFields = ['price', 'name', 'createdAt', 'rating']
const sortField = allowedSortFields.includes(sort) ? sort : 'createdAt'
const sortOrder = order === 'asc' ? 1 : -1
const skip = (Number(page) - 1) * Number(limit)
const [products, total] = await Promise.all([
Product.find(filter)
.sort({ [sortField]: sortOrder })
.skip(skip)
.limit(Number(limit)),
Product.countDocuments(filter),
])
return success(res, products, 200, {
total,
page: Number(page),
limit: Number(limit),
hasNext: skip + products.length < total,
hasPrev: Number(page) > 1,
})
} catch (err) {
next(err)
}
})Pagination Strategies
Offset Pagination (Page-Based)
GET /users?page=3&limit=20- Simple to implement and understand
- Works well for static datasets
- Degrades on large datasets (high
skipvalues are slow in databases) - Breaks if records are inserted/deleted between page requests
const page = Math.max(1, parseInt(req.query.page) || 1)
const limit = Math.min(100, parseInt(req.query.limit) || 20) // cap at 100
const skip = (page - 1) * limitCursor Pagination (Keyset-Based)
GET /posts?cursor=eyJpZCI6NDF9&limit=20- Better performance on large datasets (uses index, not OFFSET)
- Stable — insertions/deletions do not shift pages
- Required for real-time feeds (Twitter-style timelines)
- Slightly more complex to implement
app.get('/posts', async (req, res, next) => {
try {
const limit = Math.min(100, parseInt(req.query.limit) || 20)
let filter = {}
if (req.query.cursor) {
const cursor = JSON.parse(Buffer.from(req.query.cursor, 'base64').toString())
filter._id = { $lt: cursor.id } // get records BEFORE this ID
}
const posts = await Post
.find(filter)
.sort({ _id: -1 })
.limit(limit + 1) // fetch one extra to check if there's a next page
const hasNext = posts.length > limit
if (hasNext) posts.pop() // remove the extra record
const nextCursor = hasNext
? Buffer.from(JSON.stringify({ id: posts[posts.length - 1]._id })).toString('base64')
: null
return success(res, posts, 200, { hasNext, nextCursor, limit })
} catch (err) {
next(err)
}
})API Versioning
APIs evolve. Breaking changes are inevitable. Version your API from day one so you can ship v2 without breaking existing clients.
URL Versioning (Recommended)
/api/v1/users
/api/v2/users// routes/v1/users.js
const router = require('express').Router()
router.get('/', listUsersV1)
router.post('/', createUserV1)
module.exports = router
// routes/v2/users.js
const router = require('express').Router()
router.get('/', listUsersV2) // different response shape or behaviour
module.exports = router
// src/index.js
const v1Users = require('./routes/v1/users')
const v2Users = require('./routes/v2/users')
app.use('/api/v1/users', v1Users)
app.use('/api/v2/users', v2Users)Header Versioning (Alternative)
GET /api/users
Accept: application/vnd.myapi.v2+jsonapp.use('/api/users', (req, res, next) => {
const version = req.headers['accept']?.match(/v(\d+)/)?.[1] || '1'
req.apiVersion = version
next()
})URL versioning is simpler, more explicit, and easier to test in a browser or curl. Use it unless you have a specific reason not to.
Input Validation
Never trust client input. Validate every request body, param, and query string before processing it:
npm install joiconst Joi = require('joi')
const createUserSchema = Joi.object({
name: Joi.string().min(2).max(100).required(),
email: Joi.string().email().required(),
password: Joi.string().min(8).max(128).required(),
role: Joi.string().valid('user', 'admin').default('user'),
})
function validate(schema) {
return (req, res, next) => {
const { error, value } = schema.validate(req.body, { abortEarly: false })
if (error) {
const details = error.details.map(d => ({
field: d.path.join('.'),
message: d.message.replace(/['"]/g, ''),
}))
return res.status(422).json({
success: false,
data: null,
error: { code: 'VALIDATION_ERROR', message: 'Validation failed', details },
})
}
req.body = value // use the sanitised, cast value
next()
}
}
// Usage
app.post('/users', validate(createUserSchema), createUser)Security Headers
Add basic security headers to every response with the helmet package:
npm install helmetconst helmet = require('helmet')
app.use(helmet())Helmet sets headers like X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, and Content-Security-Policy — protecting against common web vulnerabilities with a single line.
Rate Limiting
Prevent abuse and brute-force attacks:
npm install express-rate-limitconst rateLimit = require('express-rate-limit')
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // max 100 requests per window per IP
standardHeaders: true,
legacyHeaders: false,
message: { success: false, error: { code: 'RATE_LIMITED', message: 'Too many requests' } },
})
app.use('/api/', limiter)
// Stricter limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { success: false, error: { code: 'RATE_LIMITED', message: 'Too many login attempts' } },
})
app.use('/api/auth/', authLimiter)Full Versioned API Structure
Here is the folder structure for a production API following all these conventions:
src/
├── index.js ← app entry point
├── app.js ← Express app (exported for testing)
├── config/
│ └── index.js ← env vars and constants
├── middleware/
│ ├── validate.js ← Joi validation middleware
│ ├── auth.js ← JWT authentication middleware
│ └── errorHandler.js ← global error handler
├── lib/
│ └── response.js ← success/error response helpers
├── routes/
│ └── v1/
│ ├── index.js ← mounts all v1 routers
│ ├── users.js
│ ├── products.js
│ └── orders.js
├── controllers/
│ ├── userController.js ← route handler functions
│ ├── productController.js
│ └── orderController.js
├── models/
│ └── User.js ← database models
└── schemas/
└── userSchemas.js ← Joi validation schemas// src/app.js
const express = require('express')
const helmet = require('helmet')
const rateLimit = require('express-rate-limit')
const v1Routes = require('./routes/v1')
const errorHandler = require('./middleware/errorHandler')
const app = express()
app.use(helmet())
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.use('/api/', rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }))
app.get('/health', (req, res) => res.json({ status: 'ok' }))
app.use('/api/v1', v1Routes)
app.use((req, res) => res.status(404).json({
success: false, data: null,
error: { code: 'NOT_FOUND', message: `${req.method} ${req.path} not found` },
}))
app.use(errorHandler)
module.exports = appNode.js Full‑Stack Course — Module 10 of 32
You now design APIs like a senior engineer. Continue to Module 11 to master Express middleware — the pipeline that powers logging, auth, validation, and more.
Summary
Great API design is a discipline, not an afterthought. Apply these conventions from day one:
- Resource naming — nouns, plural, lowercase-hyphenated, nested max 2 levels
- HTTP methods — GET retrieves, POST creates, PUT replaces, PATCH updates, DELETE removes
- Consistent response shape —
{ success, data, meta, error }everywhere - Filtering/sorting/search — always query parameters, never path segments
- Pagination — offset for simple cases, cursor for large or real-time datasets
- API versioning — URL versioning (
/api/v1/) from day one - Validation — Joi schema on every request before it touches your business logic
- Security —
helmetfor headers,express-rate-limitfor abuse prevention
Continue to Module 11: Express Middleware — Request Pipeline Deep Dive →
Content powered by SearchFit.ai — for automated content at scale, visit https://searchfit.ai
