Back to Blog
How to Build a REST API with Node.js, Express.js, and MongoDB
nodejs
expressjs
mongodb
rest-api
javascript
web-development
saas

How to Build a REST API with Node.js, Express.js, and MongoDB

13 min read
By Istiak Rahman Fahim
# How to Build a REST API with Node.js, Express.js, and MongoDB

A REST API is the contract between a frontend and your data layer. If that contract is unclear, every feature added later becomes harder to test, secure, and maintain. This guide shows how to build a practical product API with Node.js, Express.js, and MongoDB, using patterns that work well from a prototype to a production service.

By the end, you will have a complete foundation: resource routes, Mongoose validation, pagination, JWT authentication, centralized error handling, and a deployment checklist. The examples use JavaScript, so the same concepts transfer to TypeScript or another backend framework.

## What the API will do

The example exposes a products resource with standard REST methods:

- `GET /api/products` lists products with pagination and sorting.
- `POST /api/products` creates a product.
- `GET /api/products/:id` retrieves one product.
- `PATCH /api/products/:id` partially updates a product.
- `DELETE /api/products/:id` soft-deletes a product.

REST does not require a particular framework or database. It describes a stateless interaction in which clients send a request, the server processes it, and the response contains the current representation of a resource. JSON is a common representation, while MongoDB provides a document-oriented store that fits this shape well.

## Prerequisites and project setup

### Install Node.js, MongoDB, and npm

You need Node.js 20 or later, npm, and either MongoDB Community Server 6+ or MongoDB Atlas. The code uses Mongoose 8, which supports current Node.js releases.

Create the project and install the runtime packages:

```bash
mkdir rest-api-node-express-mongodb
cd rest-api-node-express-mongodb
npm init -y
npm install express mongoose dotenv cors helmet express-rate-limit jsonwebtoken bcryptjs
npm install -D nodemon
```

### Add scripts and environment variables

Add `dev` and `start` scripts to `package.json`:

```bash
npm pkg set scripts.dev='nodemon src/server.js'
npm pkg set scripts.start='node src/server.js'
```

Create a `.env.example` file and keep the real values out of version control:

```env
PORT=3000
MONGODB_URI=mongodb://127.0.0.1:27017/products_api
CORS_ORIGIN=http://localhost:5173
JWT_SECRET=replace-with-a-long-random-secret
NODE_ENV=development
```

Use a long, random secret generated by a password manager or secrets manager. Never commit `.env` files, and rotate the JWT secret when rotating application credentials.

## Recommended project structure

A small project can stay flat, but separating concerns makes the API easier to review and test:

```text
src/
app.js
config/db.js
controllers/productController.js
middleware/auth.js
models/Product.js
models/User.js
routes/products.js
utils/ApiError.js
utils/asyncHandler.js
server.js
```

The controller owns request and response logic, the model owns persistence rules, and middleware handles cross-cutting concerns such as authentication. Keeping those responsibilities separate prevents a route file from becoming a collection of database queries.

## Define the product schema

Validation belongs close to the database, not only in a route handler. Mongoose schemas provide type checking, defaults, and validators. A slug gives clients a stable, readable identifier without exposing an internal ObjectId.

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

const productSchema = new mongoose.Schema(
{
name: { type: String, required: true, trim: true, maxlength: 100 },
slug: { type: String, required: true, trim: true, unique: true },
description: { type: String, required: true, trim: true, maxlength: 2000 },
price: { type: Number, required: true, min: 0 },
inventory: { type: Number, required: true, min: 0 },
category: { type: String, required: true, trim: true },
active: { type: Boolean, default: true }
},
{ timestamps: true }
);

productSchema.pre('save', function () {
if (!this.slug && this.name) {
this.slug = this.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
});

module.exports = mongoose.model('Product', productSchema);
```

The slug generation is a convenience, not a substitute for business rules. If slugs must be unique across teams or languages, validate that rule explicitly. Mongoose's unique index will reject a duplicate, and the duplicate-key error should be translated into a useful client response.

## Connect MongoDB and configure Express

### Connect MongoDB

Load environment variables before importing modules that read them. The database helper should fail early when MongoDB cannot be reached, rather than allowing the server to start in an uncertain state.

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

module.exports = async function connectDb(uri) {
try {
await mongoose.connect(uri);
console.log(`MongoDB connected: ${mongoose.connection.host}`);
} catch (error) {
console.error('MongoDB connection failed:', error.message);
process.exit(1);
}
};
```

### Configure the Express application

Configure security middleware once, then mount routes under `/api`. This keeps browser origins and rate limits explicit instead of relying on defaults that may be too permissive.

```js
// src/app.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const ApiError = require('./utils/ApiError');
const errorHandler = require('./middleware/errorHandler');
const productRoutes = require('./routes/products');

const app = express();

app.disable('x-powered-by');
app.use(helmet());
app.use(cors({ origin: process.env.CORS_ORIGIN || false }));
app.use(rateLimit({
windowMs: 60 * 1000,
limit: 100,
standardHeaders: true,
legacyHeaders: false
}));
app.use(express.json({ limit: '20kb' }));

app.use('/api/products', productRoutes);
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.use((req, res, next) => next(new ApiError('Route not found', 404)));
app.use(errorHandler);

module.exports = app;
```

`helmet` supplies common response-header protections. CORS limits which browser origins can send requests; it is not an authentication mechanism. Rate limiting protects the process from accidental or malicious request floods, but it should be combined with authentication and database protections for public endpoints.

Start the server with:

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

const port = process.env.PORT || 3000;

connectDb(process.env.MONGODB_URI).then(() => {
const server = app.listen(port, () => {
console.log(`API listening on port ${port}`);
});

server.on('close', async () => {
await mongoose.disconnect();
});
});
```

## Build product routes and controllers

A controller should be small and return a consistent shape. The list endpoint below validates numeric query parameters, caps the page size, allows only known sort fields, and runs the query and count in parallel.

### Add shared utilities

```js
// src/utils/asyncHandler.js
module.exports = function asyncHandler(fn) {
return function wrapped(req, res, next) {
return Promise.resolve(fn(req, res, next)).catch(next);
};
};

// src/utils/ApiError.js
class ApiError extends Error {
constructor(message, status = 500) {
super(message);
this.status = status;
}
}

module.exports = ApiError;
```

The controller uses `Promise.all` because the page count and document query are independent. For very large collections, an aggregation-based count or a cached total may be more appropriate than `countDocuments`.

### List, create, and retrieve products

```js
// src/controllers/productController.js
const Product = require('../models/Product');
const asyncHandler = require('../utils/asyncHandler');
const ApiError = require('../utils/ApiError');

function positiveInteger(value, fallback) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}

exports.listProducts = asyncHandler(async (req, res) => {
const page = positiveInteger(req.query.page, 1);
const limit = Math.min(100, positiveInteger(req.query.limit, 20));
const sortFields = ['price', 'inventory', 'createdAt'];
const requestedSort = String(req.query.sort || '-createdAt');
const sortField = sortFields.includes(requestedSort) ? requestedSort : '-createdAt';
const sortDirection = sortField.startsWith('-') ? -1 : 1;
const query = { active: true };

if (req.query.category) {
query.category = req.query.category;
}

const [products, total] = await Promise.all([
Product.find(query)
.select('-description')
.sort({ [sortField]: sortDirection })
.skip((page - 1) * limit)
.limit(limit),
Product.countDocuments(query)
]);

res.json({
data: products,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
});
});

exports.createProduct = asyncHandler(async (req, res) => {
const product = await Product.create(req.body);
res.status(201).json({ success: true, data: product });
});

exports.getProduct = asyncHandler(async (req, res) => {
const product = await Product.findOne({ _id: req.params.id, active: true });

if (!product) {
throw new ApiError('Product not found', 404);
}

res.json({ success: true, data: product });
});
```

### Update and delete products

Use `PATCH` for partial updates and `runValidators` so modified fields are checked. A soft delete keeps an audit-friendly record while preventing inactive products from appearing in normal reads.

```js
exports.updateProduct = asyncHandler(async (req, res) => {
const product = await Product.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
);

if (!product) {
throw new ApiError('Product not found', 404);
}

res.json({ success: true, data: product });
});

exports.deleteProduct = asyncHandler(async (req, res) => {
const product = await Product.findByIdAndUpdate(
req.params.id,
{ active: false },
{ new: true, runValidators: true }
);

if (!product) {
throw new ApiError('Product not found', 404);
}

res.json({ success: true, data: product });
});
```

### Declare the routes

The route layer should only describe the HTTP surface. Authorization, logging, and response formatting can be added without rewriting the workflow.

```js
// src/routes/products.js
const express = require('express');
const {
listProducts,
createProduct,
getProduct,
updateProduct,
deleteProduct
} = require('../controllers/productController');

const router = express.Router();

router.get('/', listProducts);
router.post('/', createProduct);
router.get('/:id', getProduct);
router.patch('/:id', updateProduct);
router.delete('/:id', deleteProduct);

module.exports = router;
```

## Add JWT authentication

Do not place authorization checks inside every controller. A reusable middleware makes it clear which routes are public and which require a verified token. Store only a bcrypt hash of a password; never store or log the plaintext password.

### Define the user model

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

const userSchema = new mongoose.Schema(
{
name: { type: String, required: true, trim: true },
email: { type: String, required: true, trim: true, unique: true },
password: { type: String, required: true, minlength: 8 },
role: { type: String, enum: ['user', 'admin'], default: 'user' }
},
{ timestamps: true }
);

userSchema.methods.matchPassword = async function (candidate) {
return bcrypt.compare(candidate, this.password);
};

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

### Verify tokens

JWT verification should check the signature, expiration, and the existence of the referenced user. The token is proof supplied by the client, not a substitute for checking permissions on each protected action.

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

async function authenticateToken(req, res, next) {
const header = req.headers.authorization || '';
const match = /^Bearer (.+)$/i.exec(header);
const token = match ? match[1] : null;

if (!token) {
return next(new ApiError('Authentication required', 401));
}

const secret = process.env.JWT_SECRET;
if (!secret) {
return next(new ApiError('Server configuration error', 500));
}

try {
const decoded = jwt.verify(token, secret);
if (!decoded.id) {
throw new ApiError('Invalid token', 401);
}

req.user = await User.findById(decoded.id).select('-password');
if (!req.user) {
throw new ApiError('Invalid token', 401);
}

next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return next(new ApiError('Token has expired', 401));
}
if (error.name === 'JsonWebTokenError') {
return next(new ApiError('Invalid token', 401));
}
next(error);
}
}

function signToken(user) {
return jwt.sign(
{ id: user._id.toString(), role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
}

module.exports = authenticateToken;
module.exports.signToken = signToken;
```

A registration handler would create the user, hash the password with `bcrypt.hash(password, 12)`, return the user without the password field, and send the token. The cost factor is a trade-off between security and latency, so benchmark it for your workload rather than copying a value blindly.

## Handle errors in one place

Express does not automatically turn rejected promises into responses. The `asyncHandler` wrapper sends rejections to the error middleware, while domain errors use the `ApiError` class. This keeps success responses predictable and makes testing easier.

```js
// src/middleware/errorHandler.js
const ApiError = require('../utils/ApiError');

module.exports = function errorHandler(err, req, res, next) {
if (err.name === 'CastError') {
return res.status(400).json({ success: false, error: 'Invalid resource id' });
}

if (err.code === 11000) {
return res.status(400).json({ success: false, error: 'A record with that value already exists' });
}

if (err.name === 'ValidationError') {
const messages = Object.values(err.errors).map(item => item.message);
return res.status(400).json({ success: false, error: messages.join('; ') });
}

console.error(err);
res.status(err.status || 500).json({ success: false, error: 'Internal server error' });
};
```

Mount the error handler after the not-found handler. Do not return stack traces or database details to clients in production. Log them server-side with request identifiers, route names, and structured metadata so operators can diagnose failures without exposing sensitive information.

## Hardening the API for production

A working CRUD API is a good start, but production behavior depends on the surrounding controls. Apply these checks before exposing the service:

1. **Use HTTPS and a trusted proxy.** Terminate TLS at a reverse proxy or managed platform, and configure Express's trust settings when forwarding headers.
2. **Keep origins specific.** Replace the example CORS origin with exact production domains. A wildcard origin is convenient during development but weakens browser-side assumptions.
3. **Validate and bound input.** Continue validating body, query, path, and header values. Cap page sizes, request bodies, and sorting fields.
4. **Protect sensitive endpoints.** Apply JWT authentication and role checks where needed. A valid token does not automatically grant every action.
5. **Back up MongoDB.** Define retention, test restores, and document how the API behaves after a restore.
6. **Add observability.** Record latency, status codes, database errors, and request IDs. Alert on sustained 5xx responses rather than only on total outages.
7. **Plan database growth.** Add indexes for frequently filtered fields, review slow queries, and avoid unbounded pagination when the data set grows.

Rate limiting by IP can be bypassed by distributed clients, so combine it with token-aware limits, database quotas, or a managed API gateway when threat requirements demand it.

## Test the HTTP contract

Use a small smoke test before adding more features. Node.js 20 includes the Fetch API, which keeps the example dependency-free:

```js
const response = await fetch('http://127.0.0.1:3000/api/products?limit=5');

if (!response.ok) {
throw new Error(await response.text());
}

const body = await response.json();
console.log(body.pagination);
```

Test at least these cases:

- an empty list and a populated list;
- an invalid ObjectId;
- missing or malformed JSON;
- a validation failure;
- an unauthorized protected route;
- a duplicate unique value;
- pagination boundaries and the maximum page size.

For larger suites, use Supertest or a similar HTTP testing library and a test database. Keep tests focused on the public contract so refactoring internal controller code does not require constant test rewrites.

## Common mistakes to avoid

- **Putting database logic in route handlers.** Routes should describe methods; controllers and services should own the workflow.
- **Trusting client input.** Query parameters, IDs, and JSON bodies are untrusted data. Validate types and ranges before database calls.
- **Returning every field by default.** Select only what a response needs, especially large descriptions, files, or password fields.
- **Using an unbounded `find`.** Pagination, sorting constraints, and indexes prevent expensive queries.
- **Forgetting `runValidators` on updates.** `findByIdAndUpdate` can otherwise bypass the normal validation path for modified paths.
- **Treating CORS as security.** CORS controls browser access; API authentication and authorization still belong on the server.
- **Logging secrets or full payloads.** Redact tokens, passwords, cookies, and personal data from logs.
- **Assuming a soft delete is permanent.** Document retention and purge policies, and query inactive records deliberately.

## Conclusion

A reliable Node.js REST API is not defined by how many endpoints it has. It is defined by clear resource boundaries, validated input, predictable responses, secure defaults, and observable failures. Start with the small product API above, test each contract, and add authentication, pagination, and indexes only where the application needs them. That disciplined approach gives you a maintainable foundation for a WordPress integration, a SaaS backend, or a larger Next.js application.
IR

Istiak Rahman

Full Stack Developer passionate about creating innovative web solutions. I love sharing knowledge and helping others learn web development.