Engineering 26 min read

Express.js Tutorial: Build a RESTful API from Scratch

Learn how to build a complete RESTful API with Express.js, MongoDB, and JWT authentication. This hands-on tutorial covers important stuff you need to learn for production apps.

Published: May 18, 2026·Updated: May 18, 2026

Technically reviewed by:

Jakir H.|Oleksandr K.
Express.js Tutorial: Build a RESTful API from Scratch

Key Takeaways

  • Express.js has over 100 million weekly npm downloads and is the default Node.js framework for REST API development in 2026.
  • A clean project structure that separates models, routes, middleware, and config, scaling from prototypes to production systems maintained by teams of full-stack developers.
  • Put security logic (password hashing, token generation) in your Mongoose models so it is enforced consistently across every route.
  • Centralized error handling with a single middleware catches Mongoose validation errors, duplicate keys, bad ObjectIds, and JWT failures in one place.
  • Every production Express API needs three security packages as a baseline: Helmet for headers, express-rate-limit for throttling, and cors for origin control.
  • Separate app.js from server.js so your Jest tests can import the app without starting a live server or occupying a port.
  • Validate all input server-side with Joi using abortEarly: false and stripUnknown: true for security and better developer experience.

Most Node.js web applications are based on Express.js. Express has been around since 2010 and with the arrival of new frameworks like Fastify and Hono it is still the most popular Node.js framework. According to npm trends, Express has more than 100 million weekly downloads, and more than 70% of Node.js developers use frameworks such as Express, NestJS, or Fastify for backend development. The ecosystem around Express is good. All tutorials, packages, and deployment guides assume Express as the default. Learning Express opens up the entire Node.js ecosystem and helps prepare you for full-stack development with frontend frameworks such as React.

I have built around 15 Express APIs over the past 8 years, ranging from simple CRUD apps to complex microservices that handle millions of requests. In this tutorial, we are going to build a complete bookstore API with MongoDB, authentication, validation, error handling, and tests. This is something you could put into production with a few additional considerations, like rate limiting and logging. 

Let us get started.

What is Express.js?

Express is a minimal, unopinionated web framework for Node.js. It adds a thin layer on top of Node.js's built-in HTTP module, making it easier to handle requests, define routes, use middleware, and send responses.

The word “unopinionated” is important here. Express is unlike Django (which comes with an ORM, admin panel, and template engine out of the box) in that it gives you almost nothing out of the box. You choose your database library, your templating engine, and your authentication strategy. The advantage of this flexibility is that some developers like to pick the best tool for each requirement. Others find it overwhelming, especially when starting their first project. And that’s why Express is so flexible for all types of projects, from lightweight microservices to large enterprise applications. To compare it with other frameworks for Node.js, see our guide to Node.js microservices architecture.

Here is the simplest possible Express app:

const express = require('express');
const app = express();

app.get('/', (req, res) => {  res.json({ message: 'Hello, Express!' }); });

app.listen(3000, () => {  console.log('Server running on http://localhost:3000'); });

Three lines to create a server. One line to define a route. One line to start it. That simplicity is what makes Express appealing to both beginners and experienced backend developers.

How Does Express.js Handle HTTP Requests

Before you start writing any code, it helps to understand the request life cycle in an Express app. When your Express server receives an HTTP request from a client (a browser, mobile app, or other service), the request goes through several steps before a response is sent back. Understanding this flow is key to troubleshooting issues and building your app correctly.

First, the request hits your global middleware stack. Middleware functions run in the order they are defined. This is where security headers (Helmet), request body parsing, CORS configuration, and rate limiting are applied. Like other middleware, Express compares the request URL and HTTP method against the given routes. Next comes the route-specific middleware; the authentication and input validation happen here. Finally, your route handler executes the business logic, communicates with the DB, and sends the response. If any step throws an error, it bypasses remaining middleware and goes directly to your error handler.

This layered architecture is what makes Express both powerful and predictable. Each concern is handled in its appropriate layer, and the middleware chain gives you explicit control over the request lifecycle. This idea is covered in depth in the official documentation of Express.js middleware. For production-grade applications, the recommended middleware order is: 1. Security headers 2. Request parsing 3. Authentication 4. Routes 5. Error handling This order has spared teams building on top of Node.js projects countless production headaches.

Setting Up the Express Project

Let’s build our bookstore API step by step. First, create a project directory/folder and install the required packages:

mkdir bookstore-api
cd bookstore-api
npm init -y

Install production dependencies

npm install express mongoose cors dotenv bcryptjs jsonwebtoken joi

Install dev dependencies

npm install -D nodemon jest supertest

Why These Dependencies Matter

Each package serves a specific purpose in your backend application, and understanding your dependency tree is an important part of development:

express: The web framework itself. It handles routing, middleware, and HTTP request/response management. Express sits on top of the native Node.js HTTP module and abstracts away the repetitive boilerplate code you would otherwise write for every endpoint.

mongoose: is a MongoDB object document mapper (ODM). Mongoose allows you to specify schemas and interact with MongoDB using JavaScript objects rather than raw queries. It offers validation, type casting, and a simple API for database operations. 

cors: Enables Cross-Origin Resource Sharing. Without this middleware, your React frontend running on localhost:3000 cannot call your API on localhost:5000. CORS is a browser security mechanism, and this package gives you fine-grained control over which origins can access your API. In production, you should always specify exact allowed origins rather than using a wildcard, as recommended by the Express.js security best practices.

dotenv: imports environment variables from a .env file into process.env. This is how you keep secrets like database URIs and JWT keys out of your source code. Never commit .env files to version control.

bcryptjs: Hashes passwords using the bcrypt algorithm. Plain text password storage is one of the most common security failures in web applications. bcrypt is specifically designed for password hashing because it is computationally expensive, making brute-force attacks impractical. The OWASP Authentication Cheatsheet recommends bcrypt with a cost factor of at least 10.

jsonwebtoken: Generates and verifies JWT tokens for stateless API authentication. JWTs will be the standard for API authentication in 2026 due to their compact size, self-contained nature, and cross-domain compatibility. This makes them suitable for single-page applications, mobile apps, and microservice architectures.

joi: Validates request data on the server side. Client-side validation improves user experience, but server-side validation is your actual security layer. Joi ensures that required fields exist, email addresses match valid patterns, and numbers fall within expected ranges. The Joi documentation provides a complete API reference.

nodemon: A development utility that watches your files and automatically restarts the server when you save changes. This eliminates the manual restart cycle during development.

jest + supertest: Jest is a testing framework, and Supertest is an HTTP assertion library. Together, they let you write automated tests that simulate real API requests and validate responses.

A clean, modular folder structure is the foundation of any scalable Express project. It ensures consistency, makes onboarding new developers easier, and separates concerns clearly:

bookstore-api/
 src/
   config/
     db.js
   middleware/
     auth.js
     errorHandler.js
     validate.js
   models/
     Book.js
     User.js
   routes/
     books.js
     auth.js
   app.js
 server.js
 .env
 package.json

This structure follows a pattern recommended by the Node.js Best Practices repository: models define your data shape and business logic, routes handle HTTP endpoint definitions, middleware processes requests before they reach your handlers, and config holds environment-specific setup code. As your application grows, you can extend this by adding a controllers/ directory to separate route definitions from business logic, and a services/ layer for reusable operations. This modular approach scales well from small APIs to large applications maintained collaboratively by teams of full-stack developers.

Update your package.json scripts:

// package.json
"scripts": {
 "start": "node server.js",
 "dev": "nodemon server.js",
 "test": "jest --verbose --forceExit"
}

Connecting to MongoDB with Mongoose

MongoDB is the most common database paired with Express.js, and Mongoose is the standard ODM for connecting the two. The connection module should be isolated in its own file so it can be reused and tested independently.

Create the database connection in src/config/db.js:

// src/config/db.js
const mongoose = require('mongoose');

async function connectDB() {  try {    const conn = await mongoose.connect(process.env.MONGODB_URI);    console.log(MongoDB connected: ${conn.connection.host});  } catch (error) {    console.error('MongoDB connection error:', error.message);    process.exit(1);  // Exit if we cannot connect to the database  } } module.exports = connectDB;

The process.exit(1) call is intentional here. If the database connection fails at startup, the application cannot function, so it is better to fail immediately and visibly rather than silently serving errors to every request. In production environments with a process manager like PM2, the application will automatically restart and attempt to reconnect.

And the .env file:

# .env
PORT=5000
MONGODB_URI=mongodb://localhost:27017/bookstore
JWT_SECRET=your-super-secret-key-change-this-in-production
JWT_EXPIRE=7d

If you do not have MongoDB installed locally, MongoDB Atlas provides a free tier that works well for development and small production workloads. Atlas handles backups, monitoring, and horizontal scaling, which removes significant operational overhead from your team. 

Defining Models with Mongoose

Models define how your data is structured , validated , and applied . Mongoose enforces this shape at the application level, giving you type casting, built-in validation, and a clean query API. Good models are the first line of defense for your data integrity, to prevent invalid data from ever reaching your database.

Book Model

// src/models/Book.js
const mongoose = require('mongoose');

const bookSchema = new mongoose.Schema({  title: {    type: String,    required: [true, 'Title is required'],    trim: true,    maxlength: [300, 'Title cannot exceed 300 characters'],  },  author: {    type: String,    required: [true, 'Author is required'],    trim: true,  },  isbn: {    type: String,    required: [true, 'ISBN is required'],    unique: true,    match: [/^\d{10,13}$/, 'ISBN must be 10 or 13 digits'],  },  description: {    type: String,    default: '',    maxlength: [2000, 'Description cannot exceed 2000 characters'],  },  price: {    type: Number,    required: [true, 'Price is required'],    min: [0, 'Price cannot be negative'],  },  category: {    type: String,    enum: ['fiction', 'non-fiction', 'science', 'technology', 'history', 'other'],    default: 'other',  },  pages: {    type: Number,    min: [1, 'Pages must be at least 1'],  },  publishedDate: {    type: Date,  },  inStock: {    type: Boolean,    default: true,  },  createdBy: {    type: mongoose.Schema.Types.ObjectId,    ref: 'User',    required: true,  }, }, {  timestamps: true,  // Adds createdAt and updatedAt automatically  toJSON: { virtuals: true },  toObject: { virtuals: true }, });

// Add text index for search functionality bookSchema.index({ title: 'text', author: 'text', description: 'text' });

// Virtual field for formatted price bookSchema.virtual('formattedPrice').get(function() {  return $${this.price.toFixed(2)}; });

module.exports = mongoose.model('Book', bookSchema);

Just a few things to note here. The schema has built-in validation, with custom error messages. The timestamps option automatically adds createdAt and updatedAt fields. The text index provides full-text search on title, author, and description. And the virtual field calculates formattedPrice on the fly, and it doesn't save it to the database.

User Model

// src/models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');

const userSchema = new mongoose.Schema({  name: {    type: String,    required: [true, 'Name is required'],    trim: true,  },  email: {    type: String,    required: [true, 'Email is required'],    unique: true,    lowercase: true,    match: [/^\S+@\S+.\S+$/, 'Please enter a valid email'],  },  password: {    type: String,    required: [true, 'Password is required'],    minlength: [8, 'Password must be at least 8 characters'],    select: false,  // Never return password in queries  },  role: {    type: String,    enum: ['user', 'admin'],    default: 'user',  }, }, { timestamps: true });

// Hash password before saving userSchema.pre('save', async function(next) {  if (!this.isModified('password')) return next();  this.password = await bcrypt.hash(this.password, 12);  next(); });

// Compare password method userSchema.methods.comparePassword = async function(candidatePassword) {  return await bcrypt.compare(candidatePassword, this.password); };

// Generate JWT token userSchema.methods.generateToken = function() {  return jwt.sign(    { id: this._id, role: this.role },    process.env.JWT_SECRET,    { expiresIn: process.env.JWT_EXPIRE }  ); };

module.exports = mongoose.model('User', userSchema);

There are a couple of design decisions in this schema that require some explanation. The timestamps: true option automatically handles the createdAt and updatedAt fields, which are a common source of bugs when developers forget to update them on changes. The text index on title, author, and description enables full-text search without a separate search service. For most small to medium applications, this is enough. For larger datasets that require fuzzy matching and relevance scoring, you'd integrate a dedicated search engine such as Elasticsearch. The virtual field formattedPrice computes a display-ready value on the fly, avoiding redundant data storage in the database. The createdBy reference establishes an ownership link to the User model, which is necessary for access control in the route handlers.

User Model with Built-in Security

// src/models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const userSchema = new mongoose.Schema({
name: {
   type: String,
   required: [true, 'Name is required'],
   trim: true,
},
email: {
   type: String,
   required: [true, 'Email is required'],
   unique: true,
   lowercase: true,
   match: [/^\S+@\S+.\S+$/, 'Please enter a valid email'],
},
password: {
   type: String,
   required: [true, 'Password is required'],
   minlength: [8, 'Password must be at least 8 characters'],
   select: false,
},
role: {
   type: String,
   enum: ['user', 'admin'],
   default: 'user',
},
}, { timestamps: true });
// Hash password before saving
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});
// Compare password method
userSchema.methods.comparePassword = async function(candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
// Generate JWT token
userSchema.methods.generateToken = function() {
return jwt.sign(
   { id: this._id, role: this.role },
   process.env.JWT_SECRET,
   { expiresIn: process.env.JWT_EXPIRE }
);
};
module.exports = mongoose.model('User', userSchema);

The User model encapsulates critical security logic directly within the data layer. The pre('save') hook automatically hashes passwords before they are stored, so you cannot accidentally save a plain text password from any route handler. The select: false directive on the password field means that password hashes are never included in query results unless you explicitly request them with .select('+password'). The comparePassword method uses bcrypt's constant-time comparison to safely check credentials without exposing timing information to attackers. The generateToken method creates a signed JWT containing the user ID and role, which the client uses for subsequent authenticated requests.

This is a pattern we use on every Node.js project at Softaims. Putting security logic in the model ensures it is enforced consistently, regardless of which route or controller initiates the operation. The Node.js best practices guide recommends this approach for reducing the surface area for security-related bugs.

Building Middleware

Middleware functions are the building blocks of an Express application. They execute during the request-response cycle, between the server receiving a request and sending a response. Each middleware function has rights to the request object, the response object, and a next function that passes control to the next middleware in the chain. This architecture provides a clean design by separating responsibilities. Authentication, validation, logging, and error handling are all handled in their own layers.

The order of the middleware stack is really important in production Express.js apps. Security middleware like Helmet should run first to ensure every response includes the proper security headers. The next thing is request parsing. The next thing is an authentication middleware that checks credentials before executing protected routes. Routes are where all the real business logic happens. The error handler is the last chance to catch any exceptions that occur during the request cycle. This order is a common industry standard for professional backend development.

Authentication Middleware

// src/middleware/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');

async function protect(req, res, next) {  let token;

 // Check for token in Authorization header  if (req.headers.authorization &&      req.headers.authorization.startsWith('Bearer')) {    token = req.headers.authorization.split(' ')[1];  }

 if (!token) {    return res.status(401).json({      error: 'Not authorized. No token provided.',    });  }

 try {    // Verify token    const decoded = jwt.verify(token, process.env.JWT_SECRET);

   // Attach user to request object    req.user = await User.findById(decoded.id);

   if (!req.user) {      return res.status(401).json({ error: 'User no longer exists' });    }

   next();  // Continue to the route handler  } catch (error) {    return res.status(401).json({ error: 'Not authorized. Invalid token.' });  } }

// Restrict to specific roles function authorize(...roles) {  return (req, res, next) => {    if (!roles.includes(req.user.role)) {      return res.status(403).json({        error: Role '${req.user.role}' is not authorized for this action,      });    }    next();  }; } module.exports = { protect, authorize };

The protect middleware is based on the Bearer Token standard of RFC 6750. It then extracts the token from the Authorization header, verifies its signature with the server’s secret key, and loads the corresponding user from the database. The database lookup is important to ensure that deleted or suspended users can’t continue using old tokens. Authorize is a higher-order middleware that enables role-based access control (RBAC) by checking the user’s role against a list of allowed roles. These two middleware functions together form a complete authentication and authorization layer.

Validation Middleware

// src/middleware/validate.js
function validate(schema) {
 return (req, res, next) => {
   const { error } = schema.validate(req.body, {
     abortEarly: false,  // Return all errors, not just the first
     stripUnknown: true, // Remove unknown fields
   });

   if (error) {      const errors = error.details.map(detail => detail.message);      return res.status(400).json({ errors });    }

   next();  }; } module.exports = validate;

By setting abortEarly: false, we tell Joi not to stop after the first validation error but to collect all of them. It’s better for the client experience because developers and users can see all the issues at once, rather than solving problems one by one. The stripUnknown: true option silently drops any fields that are not defined in the schema. This is a security feature to avoid unexpected data from reaching your route handlers and can help prevent mass assignment vulnerabilities.

Error Handler

// src/middleware/errorHandler.js
function errorHandler(err, req, res, next) {
 console.error(err.stack);

 // Mongoose validation error  if (err.name === 'ValidationError') {    const messages = Object.values(err.errors).map(e => e.message);    return res.status(400).json({ errors: messages });  }

 // Mongoose duplicate key error  if (err.code === 11000) {    const field = Object.keys(err.keyValue)[0];    return res.status(400).json({      error: A record with this ${field} already exists,    });  }

 // Mongoose bad ObjectId  if (err.name === 'CastError') {    return res.status(400).json({ error: 'Invalid ID format' });  }

 // JWT errors  if (err.name === 'JsonWebTokenError') {    return res.status(401).json({ error: 'Invalid token' });  }

 // Default server error  res.status(err.statusCode || 500).json({    error: err.message || 'Internal server error',  }); } module.exports = errorHandler;

A centralized error handler is considered a best practice in Express.js production for several reasons. First, it prevents stack traces and internal implementation details from leaking to the client, which is a concern for both usability and security. Second, it provides a single location to add logging, monitoring integration, or alerting for production environments. Third, it translates technical database and library errors into clean, user-friendly messages. In production, you would replace console.error with a structured logging library like Pino or Winston to enable log aggregation and analysis.

Building CRUD Routes for Your Express REST API

Routes define the endpoints that clients interact with. Each route maps an HTTP method and URL pattern to a handler function. Well-designed REST endpoints follow predictable naming conventions and use appropriate HTTP status codes, making your API intuitive for frontend developers and third-party integrators.

Book Routes

// src/routes/books.js
const express = require('express');
const router = express.Router();
const Joi = require('joi');
const Book = require('../models/Book');
const { protect, authorize } = require('../middleware/auth');
const validate = require('../middleware/validate');

// Validation schemas const bookSchema = Joi.object({  title: Joi.string().required().max(300),  author: Joi.string().required(),  isbn: Joi.string().required().pattern(/^\d{10,13}$/),  description: Joi.string().max(2000),  price: Joi.number().required().min(0),  category: Joi.string().valid(    'fiction','non-fiction','science','technology','history','other'  ),  pages: Joi.number().integer().min(1),  publishedDate: Joi.date(),  inStock: Joi.boolean(), });

// GET /api/books - List all books (public) router.get('/', async (req, res, next) => {  try {    // Build query from query string parameters    const query = {};

   if (req.query.category) query.category = req.query.category;    if (req.query.inStock) query.inStock = req.query.inStock === 'true';    if (req.query.search) {      query.$text = { $search: req.query.search };    }

   // Pagination    const page = parseInt(req.query.page) || 1;    const limit = parseInt(req.query.limit) || 20;    const skip = (page - 1) * limit;

   // Sorting    const sort = req.query.sort || '-createdAt';

   const [books, total] = await Promise.all([      Book.find(query)        .sort(sort)        .skip(skip)        .limit(limit)        .populate('createdBy', 'name'),      Book.countDocuments(query),    ]);

   res.json({      data: books,      pagination: {        page,        limit,        total,        pages: Math.ceil(total / limit),      },    });  } catch (error) {    next(error);  } });

// GET /api/books/:id - Get single book (public) router.get('/:id', async (req, res, next) => {  try {    const book = await Book.findById(req.params.id)      .populate('createdBy', 'name email');

   if (!book) {      return res.status(404).json({ error: 'Book not found' });    }

   res.json({ data: book });  } catch (error) {    next(error);  } });

// POST /api/books - Create book (authenticated) router.post('/', protect, validate(bookSchema), async (req, res, next) => {  try {    const book = await Book.create({      ...req.body,      createdBy: req.user._id,    });    res.status(201).json({ data: book });  } catch (error) {    next(error);  } });

// PUT /api/books/:id - Update book (owner or admin) router.put('/:id', protect, async (req, res, next) => {  try {    let book = await Book.findById(req.params.id);

   if (!book) {      return res.status(404).json({ error: 'Book not found' });    }

   // Check ownership    if (book.createdBy.toString() !== req.user._id.toString()        && req.user.role !== 'admin') {      return res.status(403).json({        error: 'Not authorized to update this book',      });    }

   book = await Book.findByIdAndUpdate(req.params.id, req.body, {      new: true,      runValidators: true,    });

   res.json({ data: book });  } catch (error) {    next(error);  } });

// DELETE /api/books/:id - Delete book (owner or admin) router.delete('/:id', protect, async (req, res, next) => {  try {    const book = await Book.findById(req.params.id);

   if (!book) {      return res.status(404).json({ error: 'Book not found' });    }

   if (book.createdBy.toString() !== req.user._id.toString()        && req.user.role !== 'admin') {      return res.status(403).json({        error: 'Not authorized to delete this book',      });    }

   await book.deleteOne();    res.json({ message: 'Book deleted successfully' });  } catch (error) {    next(error);  } });

module.exports = router;

Let me walk through some important patterns here. Each handler wraps its logic in try/catch blocks and passes errors to next(error), which then forwards them to the centralized error handler we defined above. The GET /api/books endpoint supports filtering by category and stock status, full-text search through MongoDB's $text operator, pagination with configurable page size, and sorting by any field. The Promise. All calls run the data and count queries in parallel, an important performance optimization that prevents sequential database round-trips. Create and update operations pass through validation middleware before the handler executes. And the update and delete operations implement ownership verification: only the user who created a book (or an admin) can modify or remove it.

These patterns represent the baseline for what a production REST API should include. They handle edge cases, validate input, check permissions, and return appropriate HTTP status codes.

Auth Routes

// src/routes/auth.js
const express = require('express');
const router = express.Router();
const Joi = require('joi');
const User = require('../models/User');
const validate = require('../middleware/validate');
const { protect } = require('../middleware/auth');

const registerSchema = Joi.object({  name: Joi.string().required().min(2).max(50),  email: Joi.string().required().email(),  password: Joi.string().required().min(8), });

const loginSchema = Joi.object({  email: Joi.string().required().email(),  password: Joi.string().required(), });

// POST /api/auth/register router.post('/register', validate(registerSchema), async (req, res, next) => {  try {    const { name, email, password } = req.body;

   // Check if user already exists    const existingUser = await User.findOne({ email });    if (existingUser) {      return res.status(400).json({ error: 'Email already registered' });    }

   const user = await User.create({ name, email, password });    const token = user.generateToken();

   res.status(201).json({      data: {        id: user._id,        name: user.name,        email: user.email,        role: user.role,      },      token,    });  } catch (error) {    next(error);  } });

// POST /api/auth/login router.post('/login', validate(loginSchema), async (req, res, next) => {  try {    const { email, password } = req.body;

   // Find user and include password field    const user = await User.findOne({ email }).select('+password');

   if (!user || !(await user.comparePassword(password))) {      return res.status(401).json({ error: 'Invalid email or password' });    }

   const token = user.generateToken();

   res.json({      data: {        id: user._id,        name: user.name,        email: user.email,        role: user.role,      },      token,    });  } catch (error) {    next(error);  } });

// GET /api/auth/me - Get current user router.get('/me', protect, async (req, res) => {  res.json({    data: {      id: req.user._id,      name: req.user.name,      email: req.user.email,      role: req.user.role,    },  }); });

module.exports = router;

Notice that the login route uses .select('+password') to explicitly include the password field, which we excluded by default in the model. The error message for failed login attempts is deliberately vague: "Invalid email or password." You should never tell the user which credential was incorrect, because that information lets attackers enumerate which email addresses are registered in your system. This is a standard security practice recommended by the OWASP Authentication Cheatsheet. For improved security in production, consider implementing refresh tokens so that access tokens can have short expiration times without forcing users to log in frequently.

Putting the Express Application Together

Add the following code to src/app.js file:
 

// src/app.js
const express = require('express');
const cors = require('cors');
const errorHandler = require('./middleware/errorHandler');
const bookRoutes = require('./routes/books');
const authRoutes = require('./routes/auth');

const app = express();

// Global middleware app.use(cors()); app.use(express.json({ limit: '10kb' }));  // Limit body size

// Routes app.use('/api/books', bookRoutes); app.use('/api/auth', authRoutes);

// Health check app.get('/api/health', (req, res) => {  res.json({ status: 'ok', timestamp: new Date().toISOString() }); });

// 404 handler app.use((req, res) => {  res.status(404).json({ error: Route ${req.originalUrl} not found }); });

// Error handler (must be last) app.use(errorHandler);

module.exports = app;

Add the following code to server.js file:

// server.js require('dotenv').config(); const app = require('./src/app'); const connectDB = require('./src/config/db');

const PORT = process.env.PORT || 5000;

async function start() {  await connectDB();  app.listen(PORT, () => {    console.log(Server running on http://localhost:${PORT});  }); }

start();

We separated app.js from server.js because this is important for testing. Your tests can import the app without starting the server, which makes them faster and avoids port conflicts. The express.json({ limit: '10kb' }) call includes a body size limit, which helps prevent denial-of-service attacks in which malicious clients send extremely large JSON payloads.

For production deployments, you should also add Helmet for security headers and express-rate-limit for request throttling. A sensible baseline is 100 requests per 15 minutes per IP for general endpoints and 10 per 15 minutes for authentication routes. These two packages, combined with the CORS middleware already in place, form the standard security baseline for any Express API. Adding them requires only a few lines of code but provides protection against common web vulnerabilities listed in the OWASP Top 10.

Testing with Jest and Supertest

Automated tests detect bugs before they reach production and document how your API is expected to behave. The combination of Jest and Supertest is the standard testing setup for Express applications. It's because Jest provides the test runner, assertions, and mocking capabilities, while Supertest handles HTTP request simulation against your Express app.

// tests/books.test.js
const request = require('supertest');
const mongoose = require('mongoose');
const app = require('../src/app');
const User = require('../src/models/User');
const Book = require('../src/models/Book');

let token; let userId;   beforeAll(async () => {   await mongoose.connect(process.env.MONGODB_URI + '_test');     // Create a test user and get token   const user = await User.create({     name: 'Test User',     email: '[email protected]',     password: 'password123',   });   userId = user._id;   token = user.generateToken(); });   afterAll(async () => {   await mongoose.connection.dropDatabase();   await mongoose.connection.close(); });   beforeEach(async () => {   await Book.deleteMany({}); });   describe('GET /api/books', () => {   it('should return empty array when no books exist', async () => {     const res = await request(app).get('/api/books');     expect(res.status).toBe(200);     expect(res.body.data).toEqual([]);     expect(res.body.pagination.total).toBe(0);   });     it('should return books with pagination', async () => {     await Book.create({       title: 'Test Book',       author: 'Test Author',       isbn: '1234567890',       price: 19.99,       pages: 200,       publishedDate: new Date(),       createdBy: userId,     });       const res = await request(app).get('/api/books');     expect(res.status).toBe(200);     expect(res.body.data.length).toBe(1);     expect(res.body.data[0].title).toBe('Test Book');   }); });   describe('POST /api/books', () => {   it('should create a book when authenticated', async () => {     const res = await request(app)       .post('/api/books')       .set('Authorization', Bearer ${token})       .send({         title: 'New Book',         author: 'New Author',         isbn: '9876543210',         price: 29.99,         pages: 300,       });       expect(res.status).toBe(201);     expect(res.body.data.title).toBe('New Book');   });     it('should reject unauthenticated requests', async () => {     const res = await request(app)       .post('/api/books')       .send({ title: 'Book', author: 'Author', isbn: '1111111111', price: 10 });       expect(res.status).toBe(401);   });     it('should validate required fields', async () => {     const res = await request(app)       .post('/api/books')       .set('Authorization', Bearer ${token})       .send({ title: 'Only Title' });       expect(res.status).toBe(400);   }); });

Now run your tests with:

npm test

These tests cover both the happy path (correct behavior under normal conditions) and edge cases (missing authentication, incomplete data). The beforeAll hook creates a test user and generates an authentication token that is reused across tests. The beforeEach hook clears the books collection before each test to ensure isolation. The afterAll hook drops the test database and closes the connection to prevent open handles.

In production projects, I aim for at least 80% code coverage on API routes. You can generate a coverage report by updating your test script to jest --verbose --forceExit --coverage. The Jest documentation covers advanced configurations including test suites, mocking external services, and integration with continuous deployment pipelines.

How Do You Deploy an Express.js API to Production

When you deploy your API, you’re not just uploading code to a server. To deploy to production, you need to configure the environment, manage the process, harden security, and monitor. Here are three deployment options, from easiest to most control:

Railway (simplest path): Push your project to GitHub, connect to Railway, and deploy. When you connect your GitHub repository, Railway automatically detects your Node.js project, installs dependencies, and starts the server. A free tier is available for development. 

Render (balanced approach): Render works similarly to Railway. Connect your GitHub repository, and Render will automatically build and deploy your Node.js application. It includes useful production features out of the box such as automatic HTTPS, custom domains, and persistent storage, while still keeping the setup process simple. Render also offers a good free tier for smaller projects and development environments. 

AWS / DigitalOcean (maximum control): Use Docker to containerize your application and deploy it to a VPS or managed container service. This requires more infrastructure knowledge but gives your team full control over the environment, networking, and scaling behavior. This approach is common in enterprise environments where DevOps teams manage the infrastructure. 

Regardless of where you deploy, make sure these things are done:

  • Set NODE_ENV=production in your environment variables. Express uses this flag to enable performance optimizations, including view template caching and reduced error verbosity in responses. 
  • Use a process manager like PM2 to keep your application running continuously and automatically restart it after crashes. PM2 also provides cluster mode, which runs multiple instances of your app across all available CPU cores. 
  • Set up proper logging with Pino or Winston instead of console.log.
  • Enable HTTPS. All data between the client and server should be encrypted in transit. Most modern hosting platforms handle TLS certificates automatically through Let's Encrypt.
  • Set rate limiting using express-rate-limit to prevent brute-force attacks and API abuse. For distributed deployments with multiple server instances, use a Redis-backed store so rate limits are enforced consistently across all instances. 
  • Add security headers with Helmet. A single app.use(helmet()) call sets sensible security defaults.

Need Experienced Express.js Developers?

Building a production-ready API goes well beyond CRUD operations. It requires robust authentication, input validation, error handling, automated testing, rate limiting, structured logging, and a reliable deployment pipeline. Getting each of these layers right, and ensuring they work together under real traffic takes hands-on experience.

At Softaims, our Node.js developers have built APIs that handle millions of requests for companies across industries. Every pattern in this tutorial is something our team implements in real client projects. Whether you need to build a new backend from scratch, scale an existing API, or add mobile app support to your platform, our engineers integrate with your workflow from day one.

Hire Node.js developers: https://softims.com/hire-nodejs-developers/

Frequently Asked Questions

What are the 6 constraints of REST APIs?

Client-Server, Stateless, Cacheable, Uniform Interface, Layered System, and Code on Demand (optional). These were defined by Roy Fielding in 2000. The first five are mandatory. If your API violates any of them, it is not technically RESTful.

Is RESTful better than SOAP?

Yes, for most modern applications. REST is lighter (JSON vs XML), faster, and easier to scale. SOAP is still a good fit for regulated industries (banking, healthcare) needing built-in WS-Security and ACID transactions.

Is Postman a REST API?

No. Postman is an API testing tool. You use it to send requests to your API and inspect responses. Alternatives: Insomnia, Thunder Client, curl.

What is the difference between a REST API and a RESTful API?

REST is the architectural style (the rules). A RESTful API is an API that follows those rules. Most developers use the terms interchangeably.

Which framework is best for REST APIs?

Depends on your stack. Express.js for flexible Node.js APIs, Fastify for speed, NestJS for enterprise TypeScript, FastAPI for Python, and Spring Boot for Java. Express has the largest ecosystem and remains the default for most teams in 2026. If you are evaluating frameworks for a microservices architecture, Express and Fastify are the most common choices in the Node.js ecosystem.

Ilya S.

Verified BadgeVerified Expert in Engineering

My name is Ilya S. and I have over 14 years of experience in the tech industry. I specialize in the following technologies: HTML, node.js, JavaScript, React, ExpressJS, etc.. I hold a degree in Masters . Some of the notable projects I’ve worked on include: Affiliates management platform, Venues finding platform, Deals finding platform, Cryptocurrency advertising, Cryptocurrency platform, etc.. I am based in Berlin, Germany. I've successfully completed 9 projects while developing at Softaims.

I'm committed to continuous learning, always striving to stay current with the latest industry trends and technical methodologies. My work is driven by a genuine passion for solving complex, real-world challenges through creative and highly effective solutions. Through close collaboration with cross-functional teams, I've consistently helped businesses optimize critical processes, significantly improve user experiences, and build robust, scalable systems designed to last.

My professional philosophy is truly holistic: the goal isn't just to execute a task, but to deeply understand the project's broader business context. I place a high priority on user-centered design, maintaining rigorous quality standards, and directly achieving business goals—ensuring the solutions I build are technically sound and perfectly aligned with the client's vision. This rigorous approach is a hallmark of the development standards at Softaims.

Ultimately, my focus is on delivering measurable impact. I aim to contribute to impactful projects that directly help organizations grow and thrive in today’s highly competitive landscape. I look forward to continuing to drive success for clients as a key professional at Softaims.

Leave a Comment

0/100

0/2000

Loading comments...

Need help building your team? Let's discuss your project requirements.

Get matched with top-tier developers within 24 hours and start your project with no pressure of long-term commitment.