Skip to content

REST API Patterns

REST isn't a protocol. It's a set of conventions. Break them consistently if you must , but don't be inconsistent
The best API is the one your consumers can predict. Resource naming , status codes , error format , pagination - if every endpoint follows the same pattern , your API is usable. If every endpoint does its own thing , your API is a puzzle

resource naming

Nouns , not verbs. Plural , not singular

GOOD:
  GET    /users              # list users
  GET    /users/:id          # get one user
  POST   /users              # create user
  PUT    /users/:id          # update user
  DELETE /users/:id          # delete user
  GET    /users/:id/orders   # user's orders

BAD:
  GET    /getUsers           # verb in URL
  POST   /createUser         # verb in URL
  PUT    /updateUser/42      # why is update in the URL?
  POST   /deleteUser/42      # DELETE exists for a reason
  GET    /userInfo           # what's "info"? inconsistent

Nested resources for relationships , but don't go deeper than 2-3 levels:

/users/:userId/orders                   # fine
/users/:userId/orders/:orderId          # fine
/users/:userId/orders/:orderId/items    # OK but getting deep
/users/:userId/orders/:orderId/items/:itemId/shipping  # too deep

status codes

Use them correctly. Don't return 200 for everything with errors in the body

// 200 OK - successful GET , PUT , PATCH
res.status(200).json({ data: users })

// 201 Created - successful POST (resource created)
res.status(201).json({ data: newUser })

// 204 No Content - successful DELETE
res.status(204).send()

// 400 Bad Request - invalid input
res.status(400).json({ error: 'Email is required' })

// 401 Unauthorized - not authenticated
res.status(401).json({ error: 'Invalid credentials' })

// 403 Forbidden - authenticated but not authorized
res.status(403).json({ error: 'Insufficient permissions' })

// 404 Not Found - resource doesn't exist
res.status(404).json({ error: 'User not found' })

// 409 Conflict - duplicate resource , state conflict
res.status(409).json({ error: 'Email already exists' })

// 422 Unprocessable Entity - validation failed
res.status(422).json({ error: 'Validation failed' , details: errors })

// 429 Too Many Requests - rate limited
res.status(429).json({ error: 'Rate limit exceeded' })

// 500 Internal Server Error - unexpected server error
res.status(500).json({ error: 'Internal server error' })

JSON response formatting

Consistent envelope format:

// SUCCESS ENVELOPE
app.get('/users/:id' , async (req , res) => {
  const user = await db.findUser(req.params.id)
  if (!user) {
    return res.status(404).json({
      success: false,
      error: 'User not found'
    })
  }
  res.json({
    success: true,
    data: user,
    meta: {
      requested_at: new Date().toISOString()
    }
  })
})

// ERROR ENVELOPE
app.use((err , req , res , next) => {
  res.status(err.statusCode || 500).json({
    success: false,
    error: err.isOperational ? err.message : 'Internal server error',
    ...(err.errors && { details: err.errors }),
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
  })
})

pagination and filtering middleware

// middleware for pagination
const paginate = (req , res , next) => {
  const page = Math.max(parseInt(req.query.page) || 1 , 1)
  const limit = Math.min(parseInt(req.query.limit) || 20 , 100)
  const offset = (page - 1) * limit

  req.pagination = { page , limit , offset }
  next()
}

// middleware for filtering
const filter = (req , res , next) => {
  const allowedFilters = ['name' , 'email' , 'role' , 'status']
  const filters = {}

  for (const key of allowedFilters) {
    if (req.query[key] !== undefined) {
      filters[key] = req.query[key]
    }
  }

  req.filters = filters
  next()
}

// usage
app.get('/users' , paginate , filter , async (req , res) => {
  const { offset , limit } = req.pagination
  const filters = req.filters

  let query = 'SELECT * FROM users WHERE 1=1'
  const params = []

  if (filters.role) {
    params.push(filters.role)
    query += ` AND role = $${params.length}`
  }

  query += ` ORDER BY id LIMIT $${params.length + 1} OFFSET $${params.length + 2}`
  params.push(limit , offset)

  const result = await db.query(query , params)

  res.json({
    success: true,
    data: result.rows,
    pagination: {
      page: req.pagination.page,
      limit: req.pagination.limit,
      total: result.totalCount  // you need a COUNT query too
    }
  })
})

API versioning

// VERSION 1 - in URL path
app.use('/api/v1/users' , userRoutesV1)
app.use('/api/v2/users' , userRoutesV2)

// VERSION 2 - in header
// client sends: Accept: application/vnd.yourapp.v2+json
app.use('/api/users' , (req , res , next) => {
  const version = req.headers['accept']?.match(/vnd\.yourapp\.v(\d+)/)?.[1]
  req.apiVersion = parseInt(version) || 1
  next()
} , userRoutes)

URL versioning is simpler and more common. Header versioning is cleaner for the URL but harder to test with curl

OpenAPI / Swagger

Document your API so consumers don't have to read your code

npm install swagger-jsdoc swagger-ui-express
const swaggerJsdoc = require('swagger-jsdoc')
const swaggerUi = require('swagger-ui-express')

const options = {
  definition: {
    openapi: '3.0.0',
    info: {
      title: 'My Express API',
      version: '1.0.0',
      description: 'Express API documentation'
    },
    servers: [
      { url: 'http://localhost:3000' , description: 'Development' }
    ]
  },
  apis: ['./src/routes/*.js']
}

const swaggerSpec = swaggerJsdoc(options)
app.use('/api-docs' , swaggerUi.serve , swaggerUi.setup(swaggerSpec))
// routes/users.js - JSDoc annotations generate the spec
/**
 * @openapi
 * /users:
 *   get:
 *     summary: List all users
 *     responses:
 *       200:
 *         description: Returns user list
 */
router.get('/' , handler)

prerequisites

express_14_database.md - PostgreSQL , MySQL , MongoDB , connection pooling , transactions


next → express_16_testing.md