Node.jsExpressBackendFull-Stack

REST API Design with Express: Best Practices

TT
TopicTrick Team
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:

  1. Client-Server — the UI and data layer are separated
  2. Stateless — every request contains all information needed to process it; no session state on the server
  3. Cacheable — responses must define whether they can be cached
  4. Uniform Interface — consistent resource identification, self-descriptive messages
  5. Layered System — clients cannot tell if they are talking directly to a server or a proxy
  6. 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

text
❌ GET  /getUsers
❌ POST /createUser
❌ PUT  /updateUser/42
❌ GET  /deleteUser/42

✅ GET    /users
✅ POST   /users
✅ PUT    /users/42
✅ DELETE /users/42

The HTTP method is the verb. The URL is the noun. Never duplicate the verb in the path.

Use Plural Nouns for Collections

text
✅ /users          (collection)
✅ /users/42       (single resource)
✅ /products
✅ /products/abc-123
✅ /orders
✅ /orders/ORD-9981

Use Lowercase and Hyphens

text
✅ /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:

text
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 7

Do 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

MethodActionIdempotentSafeBody
GETRetrieveNo
POSTCreateYes
PUTReplace (full update)Yes
PATCHPartial updateYes
DELETERemoveNo
HEADLike GET but no bodyNo
OPTIONSDescribe allowed methodsNo
  • Idempotent — calling it multiple times has the same effect as calling it once
  • Safe — does not modify data
javascript
// 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/42

Consistent 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

json
{
  "success": true,
  "data": {
    "id": 42,
    "name": "Alice",
    "email": "alice@example.com"
  },
  "meta": null
}

Collection Response (with pagination)

json
{
  "success": true,
  "data": [
    { "id": 1, "name": "Alice" },
    { "id": 2, "name": "Bob" }
  ],
  "meta": {
    "total": 84,
    "page": 1,
    "limit": 20,
    "hasNext": true,
    "hasPrev": false
  }
}

Error Response

json
{
  "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:

javascript
// 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 }
javascript
// 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:

text
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-01

Implementing Filters in Express

javascript
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)

text
GET /users?page=3&limit=20
  • Simple to implement and understand
  • Works well for static datasets
  • Degrades on large datasets (high skip values are slow in databases)
  • Breaks if records are inserted/deleted between page requests
javascript
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) * limit

Cursor Pagination (Keyset-Based)

text
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
javascript
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)

text
/api/v1/users
/api/v2/users
javascript
// 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)

text
GET /api/users
Accept: application/vnd.myapi.v2+json
javascript
app.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:

javascript
npm install joi
javascript
const 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:

bash
npm install helmet
javascript
const 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:

bash
npm install express-rate-limit
javascript
const 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:

text
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
javascript
// 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 = app

Node.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
    • Securityhelmet for headers, express-rate-limit for 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