Build a REST API With Node.js + Express + MongoDB (Full Tutorial)
Step-by-step tutorial to build a production-ready CRUD REST API using Node.js, Express, and MongoDB with models, routes, controllers, and error handling.
Get more content like this on Telegram!
Daily AI tips, notes & resources ā free
I've built a fair number of REST APIs at this point, and the Node.js + Express + MongoDB combo remains one of the fastest ways to go from nothing to a working API. It's not always the right choice for every project ā we'll talk about alternatives ā but for full-stack JavaScript projects and rapid prototyping, it's hard to beat.
This tutorial builds a complete CRUD API for a blog application: create posts, read posts, update them, delete them. By the end you'll have a structured codebase with models, routes, controllers, and proper error handling middleware.
Project Setup
Start by creating the project structure:
mkdir blog-api && cd blog-api
npm init -y
npm install express mongoose dotenv
npm install --save-dev nodemon
Create this folder structure:
blog-api/
āāā src/
ā āāā config/
ā ā āāā db.js
ā āāā models/
ā ā āāā Post.js
ā āāā controllers/
ā ā āāā postController.js
ā āāā routes/
ā ā āāā postRoutes.js
ā āāā middleware/
ā ā āāā errorHandler.js
ā āāā app.js
āāā .env
āāā server.js
Add this to package.json:
{
"scripts": {
"dev": "nodemon server.js",
"start": "node server.js"
}
}
.env file:
PORT=3000
MONGODB_URI=mongodb://localhost:27017/blog-api
NODE_ENV=development
For production, you'd use a MongoDB Atlas connection string here. The MongoDB Atlas free tier setup guide walks through getting that connection string.
Database Connection
// src/config/db.js
const mongoose = require('mongoose');
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI, {
serverSelectionTimeoutMS: 5000,
});
console.log(`MongoDB connected: ${conn.connection.host}`);
} catch (error) {
console.error(`MongoDB connection error: ${error.message}`);
process.exit(1);
}
};
module.exports = connectDB;
Calling process.exit(1) here is intentional ā if the database isn't available, there's no point running the API.
The Post Model
// src/models/Post.js
const mongoose = require('mongoose');
const postSchema = new mongoose.Schema(
{
title: {
type: String,
required: [true, 'Title is required'],
trim: true,
maxlength: [200, 'Title cannot exceed 200 characters'],
},
content: {
type: String,
required: [true, 'Content is required'],
minlength: [10, 'Content must be at least 10 characters'],
},
author: {
type: String,
required: [true, 'Author is required'],
trim: true,
},
tags: {
type: [String],
default: [],
},
status: {
type: String,
enum: {
values: ['draft', 'published', 'archived'],
message: 'Status must be draft, published, or archived',
},
default: 'draft',
},
publishedAt: {
type: Date,
default: null,
},
},
{
timestamps: true, // adds createdAt and updatedAt automatically
}
);
// Auto-set publishedAt when status changes to published
postSchema.pre('save', function (next) {
if (this.isModified('status') && this.status === 'published' && !this.publishedAt) {
this.publishedAt = new Date();
}
next();
});
// Index for faster queries
postSchema.index({ status: 1, createdAt: -1 });
postSchema.index({ tags: 1 });
module.exports = mongoose.model('Post', postSchema);
The timestamps: true option is one of those Mongoose conveniences worth knowing ā it automatically manages createdAt and updatedAt fields without you having to touch them.
For the difference between Mongoose and other ORMs/ODMs, there's a comparison table coming up later in this guide. If you want to explore SQL-based options, Prisma ORM with PostgreSQL is worth reading.
Controllers: The Business Logic Layer
// src/controllers/postController.js
const Post = require('../models/Post');
// GET /api/v1/posts
const getPosts = async (req, res, next) => {
const page = parseInt(req.query.page) || 1;
const limit = Math.min(parseInt(req.query.limit) || 10, 50);
const skip = (page - 1) * limit;
const status = req.query.status;
const tag = req.query.tag;
const filter = {};
if (status) filter.status = status;
if (tag) filter.tags = tag;
const [posts, total] = await Promise.all([
Post.find(filter).sort({ createdAt: -1 }).skip(skip).limit(limit).lean(),
Post.countDocuments(filter),
]);
res.status(200).json({
data: posts,
meta: {
total,
page,
limit,
pages: Math.ceil(total / limit),
},
});
};
// GET /api/v1/posts/:id
const getPost = async (req, res, next) => {
const post = await Post.findById(req.params.id).lean();
if (!post) {
const error = new Error('Post not found');
error.statusCode = 404;
return next(error);
}
res.status(200).json({ data: post });
};
// POST /api/v1/posts
const createPost = async (req, res, next) => {
const { title, content, author, tags, status } = req.body;
const post = await Post.create({ title, content, author, tags, status });
res.status(201).json({ data: post });
};
// PUT /api/v1/posts/:id
const updatePost = async (req, res, next) => {
const { title, content, author, tags, status } = req.body;
const post = await Post.findById(req.params.id);
if (!post) {
const error = new Error('Post not found');
error.statusCode = 404;
return next(error);
}
// Update only provided fields
if (title !== undefined) post.title = title;
if (content !== undefined) post.content = content;
if (author !== undefined) post.author = author;
if (tags !== undefined) post.tags = tags;
if (status !== undefined) post.status = status;
await post.save(); // triggers pre-save middleware
res.status(200).json({ data: post });
};
// DELETE /api/v1/posts/:id
const deletePost = async (req, res, next) => {
const post = await Post.findByIdAndDelete(req.params.id);
if (!post) {
const error = new Error('Post not found');
error.statusCode = 404;
return next(error);
}
res.status(204).send();
};
module.exports = { getPosts, getPost, createPost, updatePost, deletePost };
Notice that controllers don't have try/catch blocks. That's intentional ā we'll handle that with a wrapper.
Error Handling Middleware
This is the part most tutorials skip, and it's the part that makes the difference between a toy API and something maintainable:
// src/middleware/errorHandler.js
// Wraps async functions to automatically catch errors and pass to next()
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Centralized error handling middleware
const errorHandler = (err, req, res, next) => {
let statusCode = err.statusCode || 500;
let message = err.message || 'Internal server error';
let details = null;
// Mongoose validation errors
if (err.name === 'ValidationError') {
statusCode = 422;
message = 'Validation failed';
details = Object.values(err.errors).map((e) => ({
field: e.path,
message: e.message,
}));
}
// Mongoose cast error (invalid ID format)
if (err.name === 'CastError' && err.kind === 'ObjectId') {
statusCode = 400;
message = 'Invalid ID format';
}
// MongoDB duplicate key error
if (err.code === 11000) {
statusCode = 409;
const field = Object.keys(err.keyValue)[0];
message = `${field} already exists`;
}
const response = {
error: {
code: statusCode >= 500 ? 'SERVER_ERROR' : 'CLIENT_ERROR',
message,
},
};
if (details) response.error.details = details;
// Don't leak stack traces in production
if (process.env.NODE_ENV === 'development') {
response.error.stack = err.stack;
}
res.status(statusCode).json(response);
};
module.exports = { asyncHandler, errorHandler };
Routes
// src/routes/postRoutes.js
const express = require('express');
const router = express.Router();
const {
getPosts,
getPost,
createPost,
updatePost,
deletePost,
} = require('../controllers/postController');
const { asyncHandler } = require('../middleware/errorHandler');
router.route('/')
.get(asyncHandler(getPosts))
.post(asyncHandler(createPost));
router.route('/:id')
.get(asyncHandler(getPost))
.put(asyncHandler(updatePost))
.delete(asyncHandler(deletePost));
module.exports = router;
App and Server Files
// src/app.js
const express = require('express');
const { errorHandler } = require('./middleware/errorHandler');
const postRoutes = require('./routes/postRoutes');
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
// Routes
app.use('/api/v1/posts', postRoutes);
// 404 handler for unknown routes
app.use((req, res) => {
res.status(404).json({
error: {
code: 'NOT_FOUND',
message: `Route ${req.method} ${req.path} not found`,
},
});
});
// Centralized error handler (must be last)
app.use(errorHandler);
module.exports = app;
// server.js
require('dotenv').config();
const app = require('./src/app');
const connectDB = require('./src/config/db');
const PORT = process.env.PORT || 3000;
connectDB().then(() => {
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
});
Testing With Postman
Once the server is running, test each endpoint:
Create a post:
POST http://localhost:3000/api/v1/posts
Content-Type: application/json
{
"title": "My First Post",
"content": "This is the content of my first post, written with care.",
"author": "Alice",
"tags": ["tutorial", "nodejs"],
"status": "published"
}
Get all published posts:
GET http://localhost:3000/api/v1/posts?status=published&page=1&limit=5
Get a specific post:
GET http://localhost:3000/api/v1/posts/64abc123def456789012345
Update a post:
PUT http://localhost:3000/api/v1/posts/64abc123def456789012345
Content-Type: application/json
{
"title": "My Updated Post Title"
}
Delete a post:
DELETE http://localhost:3000/api/v1/posts/64abc123def456789012345
For documenting these endpoints, the OpenAPI/Swagger guide shows how to generate interactive docs from your Express app.
ODM/ORM Comparison Table
| Tool | Database | Type | Schema | Query Style | Learning Curve | Performance |
|---|---|---|---|---|---|---|
| Mongoose | MongoDB | ODM | Optional (schema-based) | JS methods | Low | Moderate |
| MongoDB Driver | MongoDB | Native | None | JS methods | Low | High |
| Prisma | PostgreSQL, MySQL, SQLite | ORM | Required (SDL) | Generated client | Medium | Moderate |
| Sequelize | PostgreSQL, MySQL, SQLite | ORM | JS-defined | JS methods | Medium | Moderate |
| TypeORM | Multiple | ORM | TypeScript classes | QueryBuilder | High | Moderate |
| Drizzle | PostgreSQL, MySQL, SQLite | ORM | TypeScript | SQL-like | Low-Medium | High |
If your project is better suited to a relational database, the Prisma ORM PostgreSQL tutorial covers a similar CRUD walkthrough with a different stack. And for raw SQL optimization, the SQL query optimization guide is worth bookmarking.
Adding Input Validation
Mongoose validation covers database-level rules, but I recommend adding explicit request validation before it reaches the model:
// src/middleware/validate.js
const validatePost = (req, res, next) => {
const { title, content, author } = req.body;
const errors = [];
if (!title || title.trim().length === 0) {
errors.push({ field: 'title', message: 'Title is required' });
}
if (!content || content.trim().length < 10) {
errors.push({ field: 'content', message: 'Content must be at least 10 characters' });
}
if (!author || author.trim().length === 0) {
errors.push({ field: 'author', message: 'Author is required' });
}
if (errors.length > 0) {
return res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Validation failed',
details: errors,
},
});
}
next();
};
module.exports = { validatePost };
Then add it to the POST route:
const { validatePost } = require('../middleware/validate');
router.post('/', validatePost, asyncHandler(createPost));
For more on securing your API beyond validation, the API authentication methods guide covers JWT, OAuth, and API key approaches.
You can also reference MongoDB's official documentation for the full Node.js driver API.
Conclusion
You now have a structured, production-ready CRUD API with proper separation of concerns: models handle data structure, controllers handle business logic, routes map URLs to controllers, and middleware handles cross-cutting concerns like errors.
The pattern here ā separate files for models, controllers, routes, and middleware ā scales well. When this project grows to 20 endpoints, the structure still makes sense. That's worth more than clever one-file solutions that become impossible to maintain.
Next steps: add authentication (JWT is straightforward ā see the API authentication guide), add rate limiting with express-rate-limit, and document your endpoints with OpenAPI. If you want to handle file uploads in your API, check out the Node.js file upload with Multer guide.
FAQ
Do I need to know MongoDB before building this API?
Basic familiarity helps but isn't required. You need to understand that MongoDB stores documents (like JSON objects) in collections (like tables). Mongoose handles the syntax ā you define a schema and it translates your JavaScript calls into MongoDB operations. The main thing to understand upfront is that MongoDB uses string IDs (_id) rather than integer primary keys.
Should I use Mongoose or the native MongoDB driver?
For most projects, Mongoose. It gives you schema validation, built-in type casting, middleware hooks (pre/post save), and query helpers that save significant boilerplate. The native driver is faster but you lose all of that structure. Use the native driver when you need maximum performance or are building something where Mongoose's abstractions genuinely get in the way.
How do I handle errors in Express without repeating try/catch everywhere?
Create a custom async error wrapper and a centralized error handling middleware. Wrap all async route handlers with the wrapper ā it catches any thrown errors and passes them to Express's next() function. The middleware at the bottom of your app.js handles all errors in one place. This pattern cuts out 90% of the try/catch repetition you'd otherwise write.
Frequently Asked Questions
AiTechWorlds Team
ā Verified WriterThe AiTechWorlds team is passionate about AI, technology, and education. We create high-quality, research-backed content to help you learn, grow, and succeed in the modern digital world.
Related Articles
How to Use Docker Compose for Local Dev (Node.js + PostgreSQL)
Set up a full local dev environment with Docker Compose, Node.js, PostgreSQL, and pgAdmin. Includes .env config, named volumes, healthchecks, and common error fixes.
5 GraphQL Resolver Best Practices (DataLoader, Error Handling)
Write efficient GraphQL resolvers that don't hammer your database. DataLoader N+1 fix, error handling patterns, auth in context, and resolver performance comparison.
How to Use MongoDB Atlas: Free Cloud Database Setup (2026)
Set up a free MongoDB Atlas cloud cluster, connect with Mongoose, and build a full CRUD app. Plus a real comparison of Atlas free vs Supabase, Neon, and PlanetScale free tiers.
How to Document Your API With OpenAPI 3.0 (Swagger Tutorial)
Write clear, interactive API docs using OpenAPI 3.0 and Swagger UI. Includes full YAML examples, Express setup, spec-first vs code-first comparison, and auto-generation tips.