Every web application you use today has an API running behind it.
The app that tracks your orders. The dashboard that shows your analytics. The banking platform that processes your transfers. All of them talk to a backend through an API.
In this guide I'm going to show you exactly how to build one — from scratch, step by step, using Node.js and MongoDB. This is the same stack I use in production for real clients.
By the end you'll have a fully working REST API with:
Create, Read, Update, Delete (CRUD) operations
Proper error handling
MongoDB database connection
Clean project structure you can build on
Let's get into it.
What You'll Need
Before we start make sure you have these installed:
Node.js (v18 or higher) — nodejs.org
MongoDB — either local installation or a free MongoDB Atlas cluster
Postman or any API testing tool
A code editor — I use VS Code
Step 1: Set Up Your Project
Create a new folder and initialise your project:
bash
mkdir my-api
cd my-api
npm init -yNow install the dependencies we need:
bash
npm install express mongoose dotenv
npm install --save-dev nodemonHere's what each one does:
express — the web framework that handles our routes and requests
mongoose — connects our app to MongoDB and gives us a clean way to work with data
dotenv — loads environment variables from a
.envfile so we don't hardcode sensitive valuesnodemon — restarts our server automatically when we make changes (dev only)
Update your package.json scripts:
json
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}Step 2: Project Structure
Set up your folders like this:
my-api/
controllers/
postController.js
models/
Post.js
routes/
postRoutes.js
.env
server.jsClean structure from the start saves you hours of pain later. Trust me.
Step 3: Create Your Environment Variables
Create a .env file in the root:
env
PORT=5000
MONGODB_URI=your_mongodb_connection_string_hereIf you're using MongoDB Atlas, your connection string looks like:
mongodb+srv://username:password@cluster.mongodb.net/myapiNever commit your .env file to GitHub. Add it to .gitignore immediately:
node_modules/
.envStep 4: Connect to MongoDB
Create server.js:
javascript
const express = require("express");
const mongoose = require("mongoose");
require("dotenv").config();
const app = express();
// Middleware
app.use(express.json());
// Routes
app.use("/api/posts", require("./routes/postRoutes"));
// Health check
app.get("/", (req, res) => {
res.json({ message: "API is running" });
});
// Connect to MongoDB and start server
const PORT = process.env.PORT || 5000;
mongoose
.connect(process.env.MONGODB_URI)
.then(() => {
console.log("MongoDB connected");
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
})
.catch((err) => {
console.error("MongoDB connection error:", err);
process.exit(1);
});Notice we only start the server after MongoDB connects successfully. This prevents the app from running without a database — a mistake that causes confusing bugs in production.
Step 5: Create Your Model
Create models/Post.js:
javascript
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"],
},
author: {
type: String,
required: [true, "Author is required"],
trim: true,
},
published: {
type: Boolean,
default: false,
},
},
{ timestamps: true }
);
module.exports = mongoose.model("Post", postSchema);A few things to notice here:
requiredhas a custom error message — this makes debugging much easiertrim: trueremoves accidental whitespace from string fieldstimestamps: trueautomatically addscreatedAtandupdatedAtfieldsmaxlengthvalidates input at the database level, not just the frontend
This is the difference between a production model and a tutorial model.
Step 6: Create Your Controllers
Create controllers/postController.js:
javascript
const Post = require("../models/Post");
// GET all posts
const getPosts = async (req, res) => {
try {
const posts = await Post.find().sort({ createdAt: -1 });
res.status(200).json({
success: true,
count: posts.length,
data: posts,
});
} catch (error) {
res.status(500).json({
success: false,
message: "Server error",
error: error.message,
});
}
};
// GET single post
const getPost = async (req, res) => {
try {
const post = await Post.findById(req.params.id);
if (!post) {
return res.status(404).json({
success: false,
message: "Post not found",
});
}
res.status(200).json({ success: true, data: post });
} catch (error) {
res.status(500).json({
success: false,
message: "Server error",
error: error.message,
});
}
};
// CREATE post
const createPost = async (req, res) => {
try {
const { title, content, author } = req.body;
const post = await Post.create({ title, content, author });
res.status(201).json({ success: true, data: post });
} catch (error) {
if (error.name === "ValidationError") {
const messages = Object.values(error.errors).map((e) => e.message);
return res.status(400).json({
success: false,
message: messages.join(", "),
});
}
res.status(500).json({
success: false,
message: "Server error",
error: error.message,
});
}
};
// UPDATE post
const updatePost = async (req, res) => {
try {
const post = await Post.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
);
if (!post) {
return res.status(404).json({
success: false,
message: "Post not found",
});
}
res.status(200).json({ success: true, data: post });
} catch (error) {
res.status(500).json({
success: false,
message: "Server error",
error: error.message,
});
}
};
// DELETE post
const deletePost = async (req, res) => {
try {
const post = await Post.findByIdAndDelete(req.params.id);
if (!post) {
return res.status(404).json({
success: false,
message: "Post not found",
});
}
res.status(200).json({
success: true,
message: "Post deleted successfully",
});
} catch (error) {
res.status(500).json({
success: false,
message: "Server error",
error: error.message,
});
}
};
module.exports = { getPosts, getPost, createPost, updatePost, deletePost };Every single route has a try/catch block. Every 404 is handled explicitly. Every error returns a consistent JSON structure.
This is what separates production code from tutorial code.
Step 7: Create Your Routes
Create routes/postRoutes.js:
javascript
const express = require("express");
const router = express.Router();
const {
getPosts,
getPost,
createPost,
updatePost,
deletePost,
} = require("../controllers/postController");
router.route("/").get(getPosts).post(createPost);
router.route("/:id").get(getPost).put(updatePost).delete(deletePost);
module.exports = router;Clean. Readable. Every route mapped to the right controller.
Step 8: Test Your API
Start your server:
bash
npm run devYou should see:
MongoDB connected
Server running on port 5000Now test your endpoints in Postman:
Create a post:
POST http://localhost:5000/api/posts
Content-Type: application/json
{
"title": "My First Post",
"content": "This is the content of my first post",
"author": "Onyedika Paul"
}Get all posts:
GET http://localhost:5000/api/postsGet single post:
GET http://localhost:5000/api/posts/:idUpdate a post:
PUT http://localhost:5000/api/posts/:id
Content-Type: application/json
{
"title": "Updated Title",
"published": true
}Delete a post:
DELETE http://localhost:5000/api/posts/:idCommon Mistakes to Avoid
1. Not handling async errors Every database operation can fail. Always wrap them in try/catch.
2. Trusting user input Validate everything before it touches your database. Use Mongoose validators and add express-validator for complex validation.
3. Exposing sensitive data Never send passwords, tokens, or internal IDs you don't need to the client. Use .select("-password") to exclude fields.
4. Not using environment variables Hardcoding your MongoDB URI or API keys in your code is a security disaster waiting to happen.
5. Ignoring HTTP status codes 200 for success. 201 for created. 400 for bad request. 404 for not found. 500 for server error. Use them correctly — they tell the client exactly what happened.
What to Add Next
This is a solid foundation. Here's what to build on top of it:
Authentication — JWT tokens to protect routes
Middleware — rate limiting, CORS, request logging
Pagination — for routes that return large datasets
Input validation — express-validator for complex validation rules
File uploads — Cloudinary integration for images
You Now Have a Production-Ready Foundation
Most tutorials stop at "it works." This one went further — proper error handling, consistent response structure, clean separation of concerns, and environment variables from the start.
That's the difference between code that works in a tutorial and code that holds up in production.
Want this built into a full application for your business? I build production-ready APIs and full-stack web apps. Get in Touch and let's talk about what you need.