Building RESTful APIs with Node.js and Express
Most Express APIs start as a single app.js with a few routes, and that's fine for a weekend. Then the frontend team asks why a missing user returns a 200 with an empty body, a bad ID crashes the process, and every endpoint formats errors differently. By that point, restructuring hurts.
This post builds a small Users API with Node.js, Express and MongoDB, set up the way I'd want it from day one: a sensible folder layout, a model with validation, controllers kept apart from routes, one error handler for everything, and a few middleware pieces (auth, rate limiting) you'll need sooner or later.
An API spends most of its time waiting on the database or other services, and Node's non-blocking I/O handles that kind of workload well. Express stays out of the way: it gives you routing and a middleware chain and leaves the rest of the decisions to you. That's a plus when you know what you want, and a minus when you'd rather have conventions decided for you.
Understanding REST Architecture
"RESTful" gets used loosely. In practice it means a handful of conventions: URLs name resources, HTTP methods say what you're doing to them, and the server doesn't keep per-client session state between requests. Follow those and other developers can guess how your API works before reading the docs.
REST API architecture: Client-Server communication via HTTP
REST Principles
- Stateless: Each request contains all information needed. The server doesn't store client session data.
- Resource-Based: Everything is a resource (users, posts, products) identified by URIs.
- HTTP Methods: Standard methods define operations on resources.
- Uniform Interface: Consistent resource naming and response formats.
- JSON Format: Data is typically exchanged as JSON.
HTTP Methods Overview
Project Setup
The example is a Users API with full CRUD. Start with the dependencies and the folder layout.
Initialize the Project
Create a new directory and initialize npm with the necessary packages.
# Create project directory
mkdir express-rest-api
cd express-rest-api
# Initialize npm (creates package.json)
npm init -y
# Install dependencies
npm install express mongoose dotenv cors helmet morgan
# Install dev dependencies
npm install -D nodemon
# Create folder structure
mkdir -p src/{config,controllers,middleware,models,routes,utils}
- express: Web framework for Node.js
- mongoose: MongoDB ODM for data modeling
- dotenv: Environment variable management
- cors: Cross-Origin Resource Sharing middleware
- helmet: Security headers middleware
- morgan: HTTP request logging
- nodemon: Auto-restart during development
Project Structure
Group files by role so you know where a new route or model goes.
express-rest-api/
├── src/
│ ├── config/
│ │ └── database.js # Database connection
│ ├── controllers/
│ │ └── userController.js # Request handlers
│ ├── middleware/
│ │ ├── errorHandler.js # Global error handling
│ │ └── validate.js # Request validation
│ ├── models/
│ │ └── User.js # Mongoose schemas
│ ├── routes/
│ │ ├── index.js # Route aggregator
│ │ └── userRoutes.js # User endpoints
│ ├── utils/
│ │ └── ApiError.js # Custom error class
│ └── app.js # Express app setup
├── .env # Environment variables
├── .gitignore
├── package.json
└── server.js # Entry point
Building the Express Server
I keep the Express app in src/app.js and the code that starts listening in server.js. The split pays off when you write tests: Supertest can import the app without opening a port or connecting to the database. Order matters in this file, since middleware runs top to bottom and the error handler has to be last.
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const routes = require('./routes');
const errorHandler = require('./middleware/errorHandler');
const app = express();
// Security middleware
app.use(helmet());
// Enable CORS for all origins (configure for production)
app.use(cors());
// Parse JSON bodies
app.use(express.json({ limit: '10mb' }));
// Parse URL-encoded bodies
app.use(express.urlencoded({ extended: true }));
// HTTP request logging
if (process.env.NODE_ENV !== 'test') {
app.use(morgan('dev'));
}
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({
status: 'OK',
timestamp: new Date().toISOString()
});
});
// API routes
app.use('/api/v1', routes);
// 404 handler
app.use((req, res) => {
res.status(404).json({
success: false,
message: `Route ${req.originalUrl} not found`
});
});
// Global error handler (must be last)
app.use(errorHandler);
module.exports = app;
require('dotenv').config();
const app = require('./src/app');
const connectDB = require('./src/config/database');
const PORT = process.env.PORT || 3000;
// Connect to database and start server
const startServer = async () => {
try {
await connectDB();
app.listen(PORT, () => {
console.log(`
🚀 Server running in ${process.env.NODE_ENV || 'development'} mode
📡 API available at http://localhost:${PORT}/api/v1
❤️ Health check at http://localhost:${PORT}/health
`);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
};
// Handle unhandled promise rejections
process.on('unhandledRejection', (err) => {
console.error('Unhandled Rejection:', err);
process.exit(1);
});
startServer();
NODE_ENV=development
PORT=3000
MONGODB_URI=mongodb://localhost:27017/express-api
JWT_SECRET=your-super-secret-key-change-in-production
Database Configuration
The connection helper connects once at startup and logs connection events, so you can see in the logs when MongoDB drops and comes back.
const mongoose = require('mongoose');
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI, {
// These options are no longer needed in Mongoose 6+
// but included for older versions
});
console.log(`📦 MongoDB Connected: ${conn.connection.host}`);
// Handle connection events
mongoose.connection.on('error', (err) => {
console.error('MongoDB connection error:', err);
});
mongoose.connection.on('disconnected', () => {
console.warn('MongoDB disconnected. Attempting to reconnect...');
});
mongoose.connection.on('reconnected', () => {
console.log('MongoDB reconnected');
});
return conn;
} catch (error) {
console.error('Database connection failed:', error.message);
throw error;
}
};
module.exports = connectDB;
Creating the User Model
The schema handles field validation, hashes the password in a pre-save hook, and adds a couple of helper methods. Note select: false on the password: queries leave it out unless you ask for it explicitly. The model uses bcryptjs, which wasn't in the install command above, so run npm install bcryptjs too.
Setting unique: true on email already creates an index. Declaring userSchema.index({ email: 1 }) as well makes recent Mongoose versions log Duplicate schema index on {"email":1} found at startup. It's harmless but noisy, so drop one of the two. Also remember unique isn't a validator. If duplicate emails already exist in the collection, the index build fails and the constraint quietly doesn't apply.
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema(
{
name: {
type: String,
required: [true, 'Name is required'],
trim: true,
minlength: [2, 'Name must be at least 2 characters'],
maxlength: [50, 'Name cannot exceed 50 characters']
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
match: [
/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/,
'Please provide a valid email'
]
},
password: {
type: String,
required: [true, 'Password is required'],
minlength: [8, 'Password must be at least 8 characters'],
select: false // Don't include in queries by default
},
role: {
type: String,
enum: ['user', 'admin', 'moderator'],
default: 'user'
},
avatar: {
type: String,
default: null
},
isActive: {
type: Boolean,
default: true
},
lastLogin: {
type: Date,
default: null
}
},
{
timestamps: true, // Adds createdAt and updatedAt
toJSON: { virtuals: true },
toObject: { virtuals: true }
}
);
// Index for faster queries
userSchema.index({ email: 1 });
userSchema.index({ createdAt: -1 });
// Virtual for user's full profile URL
userSchema.virtual('profileUrl').get(function () {
return `/users/${this._id}`;
});
// Pre-save hook: Hash password before saving
userSchema.pre('save', async function (next) {
// Only hash if password is modified
if (!this.isModified('password')) return next();
// Hash password with bcrypt
const salt = await bcrypt.genSalt(12);
this.password = await bcrypt.hash(this.password, salt);
next();
});
// Instance method: Compare passwords
userSchema.methods.comparePassword = async function (candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};
// Instance method: Return user without sensitive data
userSchema.methods.toPublicJSON = function () {
const obj = this.toObject();
delete obj.password;
delete obj.__v;
return obj;
};
// Static method: Find by email
userSchema.statics.findByEmail = function (email) {
return this.findOne({ email: email.toLowerCase() });
};
module.exports = mongoose.model('User', userSchema);
Routing and Controllers
Route files only map URLs and methods to handlers. The actual work happens in controllers, which keeps the route file readable as a list of endpoints.
Request flow: router, then middleware, then controller, then response
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
const { validateUser, validateUserUpdate } = require('../middleware/validate');
// GET /api/v1/users - Get all users
router.get('/', userController.getAllUsers);
// GET /api/v1/users/:id - Get single user
router.get('/:id', userController.getUserById);
// POST /api/v1/users - Create new user
router.post('/', validateUser, userController.createUser);
// PUT /api/v1/users/:id - Update user (full)
router.put('/:id', validateUserUpdate, userController.updateUser);
// PATCH /api/v1/users/:id - Update user (partial)
router.patch('/:id', userController.patchUser);
// DELETE /api/v1/users/:id - Delete user
router.delete('/:id', userController.deleteUser);
module.exports = router;
const express = require('express');
const router = express.Router();
const userRoutes = require('./userRoutes');
// Mount routes
router.use('/users', userRoutes);
// Add more routes here as your API grows
// router.use('/posts', postRoutes);
// router.use('/products', productRoutes);
module.exports = router;
API Endpoints
Retrieve all users with pagination and filtering support.
Retrieve a single user by their unique ID.
Create a new user. Requires name, email, and password in request body.
Replace an existing user entirely. All fields required.
Delete a user by ID. Returns 204 No Content on success.
Implementing Controllers
Each controller reads the request, talks to the model, and sends a response. The asyncHandler wrapper at the top matters more than it looks. Express 4 doesn't catch rejected promises from async handlers, so without it a thrown error leaves the request hanging until the client times out. Express 5 handles this natively, but plenty of projects are still on 4.
const User = require('../models/User');
const ApiError = require('../utils/ApiError');
// Helper for async error handling
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
/**
* @desc Get all users
* @route GET /api/v1/users
* @access Public
*/
exports.getAllUsers = asyncHandler(async (req, res) => {
// Pagination
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 10;
const skip = (page - 1) * limit;
// Filtering
const filter = {};
if (req.query.role) filter.role = req.query.role;
if (req.query.isActive) filter.isActive = req.query.isActive === 'true';
// Sorting
const sort = req.query.sort || '-createdAt';
// Execute query
const [users, total] = await Promise.all([
User.find(filter)
.select('-password')
.sort(sort)
.skip(skip)
.limit(limit),
User.countDocuments(filter)
]);
res.status(200).json({
success: true,
count: users.length,
total,
page,
pages: Math.ceil(total / limit),
data: users
});
});
/**
* @desc Get single user by ID
* @route GET /api/v1/users/:id
* @access Public
*/
exports.getUserById = asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id).select('-password');
if (!user) {
throw new ApiError(404, `User not found with id: ${req.params.id}`);
}
res.status(200).json({
success: true,
data: user
});
});
/**
* @desc Create new user
* @route POST /api/v1/users
* @access Public
*/
exports.createUser = asyncHandler(async (req, res) => {
const { name, email, password, role } = req.body;
// Check if user already exists
const existingUser = await User.findByEmail(email);
if (existingUser) {
throw new ApiError(400, 'Email already registered');
}
// Create user
const user = await User.create({
name,
email,
password,
role: role || 'user'
});
res.status(201).json({
success: true,
message: 'User created successfully',
data: user.toPublicJSON()
});
});
/**
* @desc Update user (full replacement)
* @route PUT /api/v1/users/:id
* @access Public
*/
exports.updateUser = asyncHandler(async (req, res) => {
const { name, email, role, isActive } = req.body;
let user = await User.findById(req.params.id);
if (!user) {
throw new ApiError(404, `User not found with id: ${req.params.id}`);
}
// Check email uniqueness if changed
if (email !== user.email) {
const emailExists = await User.findByEmail(email);
if (emailExists) {
throw new ApiError(400, 'Email already in use');
}
}
// Update fields
user.name = name;
user.email = email;
user.role = role || user.role;
user.isActive = isActive !== undefined ? isActive : user.isActive;
await user.save();
res.status(200).json({
success: true,
message: 'User updated successfully',
data: user.toPublicJSON()
});
});
/**
* @desc Partial update user
* @route PATCH /api/v1/users/:id
* @access Public
*/
exports.patchUser = asyncHandler(async (req, res) => {
const updates = req.body;
const allowedUpdates = ['name', 'email', 'role', 'isActive', 'avatar'];
// Filter out non-allowed fields
const filteredUpdates = Object.keys(updates)
.filter(key => allowedUpdates.includes(key))
.reduce((obj, key) => {
obj[key] = updates[key];
return obj;
}, {});
if (Object.keys(filteredUpdates).length === 0) {
throw new ApiError(400, 'No valid fields to update');
}
const user = await User.findByIdAndUpdate(
req.params.id,
filteredUpdates,
{ new: true, runValidators: true }
).select('-password');
if (!user) {
throw new ApiError(404, `User not found with id: ${req.params.id}`);
}
res.status(200).json({
success: true,
message: 'User updated successfully',
data: user
});
});
/**
* @desc Delete user
* @route DELETE /api/v1/users/:id
* @access Public
*/
exports.deleteUser = asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
throw new ApiError(404, `User not found with id: ${req.params.id}`);
}
await user.deleteOne();
res.status(204).send();
});
Error Handling
Two pieces: an ApiError class you throw from controllers, and one error middleware that turns anything thrown (your errors, Mongoose errors, JWT errors) into the same JSON shape. Express recognizes error middleware by its four arguments, so keep next in the signature even if a linter complains it's unused.
class ApiError extends Error {
constructor(statusCode, message, errors = []) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = true;
this.errors = errors;
Error.captureStackTrace(this, this.constructor);
}
// Factory methods for common errors
static badRequest(message, errors) {
return new ApiError(400, message, errors);
}
static unauthorized(message = 'Unauthorized') {
return new ApiError(401, message);
}
static forbidden(message = 'Forbidden') {
return new ApiError(403, message);
}
static notFound(message = 'Resource not found') {
return new ApiError(404, message);
}
static internal(message = 'Internal server error') {
return new ApiError(500, message);
}
}
module.exports = ApiError;
const ApiError = require('../utils/ApiError');
const errorHandler = (err, req, res, next) => {
let error = { ...err };
error.message = err.message;
error.stack = err.stack;
// Log error for debugging
console.error('Error:', {
message: err.message,
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
});
// Mongoose bad ObjectId
if (err.name === 'CastError') {
error = new ApiError(400, `Invalid ${err.path}: ${err.value}`);
}
// Mongoose duplicate key
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
error = new ApiError(400, `${field} already exists`);
}
// Mongoose validation error
if (err.name === 'ValidationError') {
const messages = Object.values(err.errors).map(e => e.message);
error = new ApiError(400, 'Validation failed', messages);
}
// JWT errors
if (err.name === 'JsonWebTokenError') {
error = new ApiError(401, 'Invalid token');
}
if (err.name === 'TokenExpiredError') {
error = new ApiError(401, 'Token expired');
}
// Send response
res.status(error.statusCode || 500).json({
success: false,
message: error.message || 'Internal server error',
errors: error.errors || [],
...(process.env.NODE_ENV === 'development' && {
stack: error.stack
})
});
};
module.exports = errorHandler;
Request Validation
Validate the request body before it reaches the controller. Mongoose validation will catch some of it, but by then you've already hit the database, and its error messages aren't always ones you want to show clients.
const ApiError = require('../utils/ApiError');
// Validation helper
const validate = (schema) => {
return (req, res, next) => {
const errors = [];
for (const [field, rules] of Object.entries(schema)) {
const value = req.body[field];
// Required check
if (rules.required && (!value || value.toString().trim() === '')) {
errors.push(`${field} is required`);
continue;
}
// Skip other validations if field is empty and not required
if (!value && !rules.required) continue;
// Type checks
if (rules.type === 'email') {
const emailRegex = /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/;
if (!emailRegex.test(value)) {
errors.push(`${field} must be a valid email`);
}
}
// Min length
if (rules.minLength && value.length < rules.minLength) {
errors.push(`${field} must be at least ${rules.minLength} characters`);
}
// Max length
if (rules.maxLength && value.length > rules.maxLength) {
errors.push(`${field} cannot exceed ${rules.maxLength} characters`);
}
// Enum check
if (rules.enum && !rules.enum.includes(value)) {
errors.push(`${field} must be one of: ${rules.enum.join(', ')}`);
}
}
if (errors.length > 0) {
return next(new ApiError(400, 'Validation failed', errors));
}
next();
};
};
// User validation schemas
exports.validateUser = validate({
name: { required: true, minLength: 2, maxLength: 50 },
email: { required: true, type: 'email' },
password: { required: true, minLength: 8 },
role: { enum: ['user', 'admin', 'moderator'] }
});
exports.validateUserUpdate = validate({
name: { required: true, minLength: 2, maxLength: 50 },
email: { required: true, type: 'email' },
role: { enum: ['user', 'admin', 'moderator'] }
});
The hand-rolled validator is fine for learning, but it falls apart with nested objects and arrays. For real projects I'd reach for joi, zod or express-validator.
Understanding Middleware
Nearly everything in Express is middleware: a function that gets req, res and next, does something, and either sends a response or calls next(). Forget both and the request just hangs, which is one of the more confusing bugs to track down.
Middleware chain: Request flows through each middleware before reaching the route handler
Authentication Middleware Example
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const ApiError = require('../utils/ApiError');
// Protect routes - require authentication
exports.protect = async (req, res, next) => {
try {
let token;
// Get token from header
if (req.headers.authorization?.startsWith('Bearer')) {
token = req.headers.authorization.split(' ')[1];
}
if (!token) {
throw new ApiError(401, 'Access denied. No token provided.');
}
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Get user from token
const user = await User.findById(decoded.id).select('-password');
if (!user) {
throw new ApiError(401, 'User no longer exists');
}
if (!user.isActive) {
throw new ApiError(401, 'User account is deactivated');
}
// Attach user to request
req.user = user;
next();
} catch (error) {
next(error);
}
};
// Restrict to certain roles
exports.restrictTo = (...roles) => {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return next(
new ApiError(403, 'You do not have permission to perform this action')
);
}
next();
};
};
// Usage in routes:
// router.get('/admin', protect, restrictTo('admin'), adminController);
Rate Limiting Middleware
// Simple in-memory rate limiter
// For production, use redis-based solution
const rateLimit = (options = {}) => {
const {
windowMs = 15 * 60 * 1000, // 15 minutes
max = 100, // Limit each IP to 100 requests per window
message = 'Too many requests, please try again later.'
} = options;
const requests = new Map();
// Cleanup old entries periodically
setInterval(() => {
const now = Date.now();
for (const [key, data] of requests.entries()) {
if (now - data.startTime > windowMs) {
requests.delete(key);
}
}
}, windowMs);
return (req, res, next) => {
const key = req.ip;
const now = Date.now();
const record = requests.get(key);
if (!record) {
requests.set(key, { count: 1, startTime: now });
return next();
}
if (now - record.startTime > windowMs) {
// Reset window
requests.set(key, { count: 1, startTime: now });
return next();
}
record.count++;
// Set rate limit headers
res.set({
'X-RateLimit-Limit': max,
'X-RateLimit-Remaining': Math.max(0, max - record.count),
'X-RateLimit-Reset': new Date(record.startTime + windowMs).toISOString()
});
if (record.count > max) {
return res.status(429).json({
success: false,
message
});
}
next();
};
};
module.exports = rateLimit;
// Usage:
// app.use('/api', rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));
Testing the API
Start the server with npm run dev (add "dev": "nodemon server.js" to your package.json scripts first) and try the endpoints with curl. If a POST comes back with "name is required" even though you sent a name, check that you passed the Content-Type: application/json header. Without it, express.json() skips the body and req.body stays empty.
# Create a user
curl -X POST http://localhost:3000/api/v1/users \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"email": "john@example.com",
"password": "securepassword123"
}'
# Get all users
curl http://localhost:3000/api/v1/users
# Get all users with pagination
curl "http://localhost:3000/api/v1/users?page=1&limit=5"
# Get single user (replace :id with actual ID)
curl http://localhost:3000/api/v1/users/65abc123def456
# Update user
curl -X PUT http://localhost:3000/api/v1/users/65abc123def456 \
-H "Content-Type: application/json" \
-d '{
"name": "John Updated",
"email": "john.updated@example.com",
"role": "admin"
}'
# Partial update
curl -X PATCH http://localhost:3000/api/v1/users/65abc123def456 \
-H "Content-Type: application/json" \
-d '{"isActive": false}'
# Delete user
curl -X DELETE http://localhost:3000/api/v1/users/65abc123def456
- Postman: Full GUI client with collections and environments
- Insomnia: Clean, fast REST client
- Thunder Client: VS Code extension for API testing
- HTTPie: User-friendly command-line HTTP client
API Response Standards
Clients branch on the status code before they look at the body, so get these right. Returning 200 with { success: false } forces every client to parse the body just to find out something failed.
| Status Code | Meaning | When to Use |
|---|---|---|
| 200 OK | Success | GET, PUT, PATCH successful |
| 201 Created | Resource created | POST successful |
| 204 No Content | Success, no body | DELETE successful |
| 400 Bad Request | Invalid input | Validation failed |
| 401 Unauthorized | Not authenticated | Missing/invalid token |
| 403 Forbidden | Not authorized | Insufficient permissions |
| 404 Not Found | Resource doesn't exist | ID not found |
| 500 Internal Error | Server error | Unexpected failures |
Authentication with JWT
Since a stateless API doesn't keep server-side sessions, the common approach is to issue a signed JWT on login and require it in the Authorization: Bearer <token> header on protected routes.
Avoid keeping JWTs in localStorage, since any XSS on the page can read them. For browser clients, an httpOnly cookie is safer. Keep access tokens short-lived (15 minutes is common) and use refresh tokens for longer sessions.
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET; // keep this out of source control!
// ── Issue a token on login ────────────────────────────────────
app.post('/api/v1/auth/login', (req, res) => {
const { email, password } = req.body;
// TODO: look up user in database and verify password hash
const user = { id: 1, email, role: 'editor' };
const token = jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ status: 'success', token });
});
// ── Auth middleware ───────────────────────────────────────────
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ status: 'error', message: 'Missing token' });
}
try {
const token = authHeader.split(' ')[1];
req.user = jwt.verify(token, JWT_SECRET);
next();
} catch (err) {
res.status(401).json({ status: 'error', message: 'Invalid or expired token' });
}
}
// ── Protect routes ───────────────────────────────────────────
app.post('/api/v1/posts', authenticate, (req, res) => { /* ... */ });
app.put('/api/v1/posts/:id', authenticate, (req, res) => { /* ... */ });
app.delete('/api/v1/posts/:id', authenticate, (req, res) => { /* ... */ });
What to Check Before Going to Production
The code above works locally. A few things in it need changing before real traffic hits it:
- Lock down CORS.
cors()with no options allows any origin. Pass an explicitoriginlist. - Don't let clients set their own role.
createUserandpatchUserboth acceptrolefrom the body, so anyone can sign up as admin. Strip it, or allow it only behindrestrictTo('admin'). - Set
trust proxybehind a load balancer. Otherwisereq.ipis the proxy's address, and the rate limiter puts every user in the same bucket. - Replace the in-memory rate limiter once you run more than one process. Each instance keeps its own counts, so the limit effectively multiplies.
express-rate-limitwith a Redis store fixes that. - Cap
limiton list endpoints. Right now?limit=100000works. Clamp it to something like 100. - Lower the JSON body limit. 10mb is generous for an API that takes user records. The default 100kb is usually enough.
Production Checklist
- Version your API: Use
/api/v1/prefix for easy upgrades - Use nouns for resources:
/usersnot/getUsers - Support filtering, sorting, pagination: Query params for list endpoints
- Implement rate limiting: Protect against abuse
- Use HTTPS: Encrypt all traffic in production
- Add request logging: Morgan or similar for debugging
- Document your API: Swagger/OpenAPI specification
- Write tests: Unit and integration tests with Jest
- Use environment variables: Never hardcode secrets
- Implement health checks:
/healthendpoint for monitoring
- Always validate and sanitize user input
- Use parameterized queries to prevent injection
- Implement proper authentication and authorization
- Set security headers with helmet
- Keep dependencies updated
- Never expose stack traces in production
When I'd Use Plain Express (and When I Wouldn't)
For a small or medium API, or a service with a narrow job, this setup is plenty. You can read every line of it and there's nothing magic going on.
The cost is that you write and maintain all the glue yourself: validation, error formatting, auth, docs. On a larger team that turns into five slightly different ways of doing the same thing. If you want structure enforced for you, NestJS (which runs on Express by default) or Fastify with its schema-based validation will save you from rebuilding it. If you're working in TypeScript, typing req.body in plain Express takes extra work that those frameworks largely handle for you.
I'd also skip REST altogether when the frontend keeps needing differently shaped slices of deeply related data. That's where GraphQL earns its complexity. For straightforward CRUD like the users example here, it's overkill.