Course
A plain to-do list works great at first: you add some tasks, and can close them after you are done. But as the tasks continue to increase, it becomes painful to use. One thing that can fix this is adding categorization, which will allow every task to belong to categories like Work, Study, Personal, etc., and you can filter the list to whichever category it aligns with.
In this tutorial, you'll learn how to build a REST API that lets you organize tasks by category using Node.js and MongoDB. By the end, you'll have a working Node.js server with five endpoints: create, list, get, update, and delete, along with a category filter. You'll also see how to enforce category validation using the native MongoDB driver, without relying on an ODM like Mongoose.
What You'll Learn
- How to build a REST API with Node.js, Express, and MongoDB, including full CRUD endpoints
- How to validate input, enforce categories, and safely handle ObjectId values effectively
- How to structure and test your API and then connect it to a frontend
You can find the complete code for this tutorial on GitHub if you'd prefer to clone it and read along as you go.
Prerequisites
Before you start, you should have:
- Node.js 18+ installed (npm is included by default)
- MongoDB running locally, or a free MongoDB Atlas cluster
- Postman or any HTTP client
- Basic knowledge of JavaScript and REST concepts
Step 1: Set Up the Project
Let's proceed to build our simple task manager. The first thing we will do is create a new folder and initialize a node project by running the commands below in our terminal:
mkdir task-manager-categories
cd task-manager-categories
npm init -y
Next, we will install the four runtime dependencies that our app needs to run: express for routing, the official MongoDB driver, dotenv for loading environment variables, and finally helmet for security headers.
npm install express mongodb dotenv helmet
For development, install nodemon as a dev dependency using the command below so the server reloads automatically when files change:
npm install --save-dev nodemon
Next, open the project folder in your IDE and update the scripts section in package.json to include the following. This will let you start the server with npm start or run it in development mode with npm run dev:
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
Here's the structure we'll be building as we go. Each folder has a clear role: db/ handles the database connection, lib/ contains helper functions, middleware/ is for Express middleware, and routes/ defines the request handlers. The public/ folder will hold a small frontend we'll add later. Even in a small project, this structure keeps things easy to follow and makes it clear where new code should go. Feel free to create the folder layout now and fill it in as you go, or skip this and add each file as we move through the steps:
task-manager-categories/
├── db/
│ └── connect.js
├── lib/
│ └── taskDocument.js
├── middleware/
│ └── parseObjectId.js
├── routes/
│ └── tasks.js
├── public/
│ ├── index.html
│ ├── styles.css
│ └── app.js
├── .env
├── .env.example
├── .gitignore
├── package.json
└── server.js
Step 2: Connect to MongoDB
To store and retrieve tasks, we first need to connect our app to MongoDB. This is done using a connection string, which tells the MongoDB driver how to connect to your database. Instead of hardcoding it directly in your code, it's better to keep it in an environment file. This keeps sensitive values out of your codebase and makes it easier to switch between environments.
Create a .env.example file to document the required variables, and a .env file for your actual values, like this:
# Copy this file to `.env` and fill in real values.
# --- Local MongoDB ---
# Use this if you're running MongoDB locally
MONGO_URI=mongodb://127.0.0.1:27017
# --- MongoDB Atlas ---
# Replace <username>, <password>, and <cluster-url> with your actual values
# Example: mongodb+srv://user:pass@cluster0.abcde.mongodb.net/
# MONGO_URI=mongodb+srv://<username>:<password>@<cluster-url>/?retryWrites=true&w=majority
DB_NAME=taskmanager
PORT=3000
Now that we have a connection string in place, we can use it to set up our MongoDB connection. Go ahead and create a db/connect.js file. This is where we'll initialize the MongoDB client and make it available to the rest of the app:
const { MongoClient } = require("mongodb");
let client;
let db;
async function connectDB() {
if (!process.env.MONGO_URI) {
throw new Error("MONGO_URI is not set. Check your .env file.");
}
client = new MongoClient(process.env.MONGO_URI, {
appName: "devrel-tutorial-javascript-crud-geeksforgeeks",
});
await client.connect();
db = client.db(process.env.DB_NAME || "taskmanager");
await db.collection("tasks").createIndex({ category: 1 });
console.log(`MongoDB connected (db: ${db.databaseName})`);
}
function getTasksCollection() {
if (!db) {
throw new Error("Database not initialized. Call connectDB() first.");
}
return db.collection("tasks");
}
async function closeDB() {
if (client) await client.close();
}
module.exports = { connectDB, getTasksCollection, closeDB };
This file sets up a single MongoDB client that the rest of the app can reuse. You only want to create this once, since the driver already handles connection pooling under the hood. Creating a new client on every request might seem fine at first, but it'll cause performance issues pretty quickly.
Step 3: Define the Task Structure and Validation
At this point, the app can connect to MongoDB. Now we need to define what a task actually looks like before we start saving anything.
This is where using the native MongoDB driver starts to feel a bit different. There's no schema file as you'd have with Mongoose. Instead, the "schema" is simply the shape of the object you insert into the database. It might feel a bit loose at first, but it's actually helpful because you stay close to what MongoDB is really doing, and nothing is hidden behind abstractions.
We still want validation, though. So, instead of scattering that logic across different routes, we'll keep it in one place so everything follows the same rules. Go ahead and create a file called lib/taskDocument.js and add the code below to it:
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
}
}
const ALLOWED_CATEGORIES = ['Work', 'Personal', 'Study', 'Other'];
function assertValidCategory(category) {
if (!ALLOWED_CATEGORIES.includes(category)) {
throw new ValidationError(
`category must be one of: ${ALLOWED_CATEGORIES.join(', ')}`
);
}
}
function buildTaskDocument(body = {}) {
if (!body.title || typeof body.title !== 'string' || !body.title.trim()) {
throw new ValidationError('title is required and must be a non-empty string');
}
if (body.category != null) {
assertValidCategory(body.category);
}
const now = new Date();
return {
title: body.title.trim(),
description: typeof body.description === 'string' ? body.description.trim() : '',
category: body.category != null ? body.category : 'Other',
completed: Boolean(body.completed),
createdAt: now,
updatedAt: now
};
}
function buildTaskUpdate(body = {}) {
const updates = {};
if (typeof body.title === 'string' && body.title.trim()) {
updates.title = body.title.trim();
}
if (typeof body.description === 'string') {
updates.description = body.description.trim();
}
if (body.category != null) {
assertValidCategory(body.category);
updates.category = body.category;
}
if (body.completed != null) {
updates.completed = Boolean(body.completed);
}
if (Object.keys(updates).length === 0) {
throw new ValidationError('no valid fields provided for update');
}
updates.updatedAt = new Date();
return updates;
}
module.exports = {
ALLOWED_CATEGORIES,
buildTaskDocument,
buildTaskUpdate,
ValidationError
};
This file acts as the gatekeeper for anything that goes into your database. Every create or update request passes through here, so you have one place enforcing the rules.
It makes sure tasks always have the right shape, keeps categories consistent, and avoids surprises later when you start querying your data. The custom ValidationError also gives you a clean way to separate bad input from real server issues, so your API can respond appropriately. With this in place, the rest of the app can stay simple. Each route can focus on its job, knowing the data it receives is already valid. Next, we'll wire up the routes and start saving tasks to MongoDB.
Step 4: Handle ObjectId and Request Validation
Now that we know what a task looks like, let's deal with how we reference one. Whenever a route includes an :id parameter, it comes in as a plain string. But MongoDB expects an ObjectId. If that string is malformed, the driver throws a pretty unhelpful error. Instead of handling that in every route, we'll centralize it with middleware so every endpoint behaves the same way.
To do this, go ahead and create a file called middleware/parseObjectId.js and add the following:
const { ObjectId } = require('mongodb');
function parseObjectId(req, res, next) {
const { id } = req.params;
if (!ObjectId.isValid(id)) {
return res.status(400).json({ error: 'invalid task id' });
}
req.taskId = new ObjectId(id);
next();
}
module.exports = parseObjectId;
This middleware runs before your route handler. It checks that the id is valid, converts it into an ObjectId, and attaches it to req.taskId. That way, by the time your route logic runs, you're always working with a proper ObjectId, and bad inputs are rejected early with a clear 400 response. Next, we'll plug this into our routes and start wiring everything together.
Step 5: Build the Task Routes (CRUD + Filtering)
At this point, most of the heavy lifting is already done. We've defined what a valid task looks like, and we've handled how IDs are parsed and validated. That means our route handlers can stay focused on one thing: talking to the database.
Let's proceed to build the actual API routes by creating a file called routes/tasks.js and adding the following to it:
const express = require('express');
const { getTasksCollection } = require('../db/connect');
const {
ALLOWED_CATEGORIES,
buildTaskDocument,
buildTaskUpdate,
ValidationError
} = require('../lib/taskDocument');
const parseObjectId = require('../middleware/parseObjectId');
const router = express.Router();
// POST /tasks
router.post('/', async (req, res, next) => {
try {
const doc = buildTaskDocument(req.body);
const result = await getTasksCollection().insertOne(doc);
res.status(201).json({ _id: result.insertedId, ...doc });
} catch (err) {
if (err instanceof ValidationError) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
// GET /tasks (optionally ?category=Work)
router.get('/', async (req, res, next) => {
try {
const { category } = req.query;
if (category && !ALLOWED_CATEGORIES.includes(category)) {
return res.status(400).json({
error: `category must be one of: ${ALLOWED_CATEGORIES.join(', ')}`
});
}
const filter = category ? { category } : {};
const tasks = await getTasksCollection()
.find(filter)
.sort({ createdAt: -1 })
.toArray();
res.json(tasks);
} catch (err) { next(err); }
});
// GET /tasks/:id
router.get('/:id', parseObjectId, async (req, res, next) => {
try {
const task = await getTasksCollection().findOne({ _id: req.taskId });
if (!task) return res.status(404).json({ error: 'task not found' });
res.json(task);
} catch (err) { next(err); }
});
// PUT /tasks/:id
router.put('/:id', parseObjectId, async (req, res, next) => {
try {
const updates = buildTaskUpdate(req.body);
const result = await getTasksCollection().findOneAndUpdate(
{ _id: req.taskId },
{ $set: updates },
{ returnDocument: 'after' }
);
if (!result) return res.status(404).json({ error: 'task not found' });
res.json(result);
} catch (err) {
if (err instanceof ValidationError) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
// DELETE /tasks/:id
router.delete('/:id', parseObjectId, async (req, res, next) => {
try {
const result = await getTasksCollection().deleteOne({ _id: req.taskId });
if (result.deletedCount === 0) {
return res.status(404).json({ error: 'task not found' });
}
res.status(204).end();
} catch (err) { next(err); }
});
module.exports = router;
Each route in the code above follows the same pattern of taking input from the request, passing it through the helpers we built earlier, calling MongoDB, and then returning a response. With validation and ID parsing already handled, the code here stays small and predictable. In practice, here's how it plays out:
- The POST route builds a new task using buildTaskDocument before inserting it
- The GET routes optionally filter by category and return results sorted by newest first
- The PUT route uses buildTaskUpdate, so partial updates are safe and consistent
- The DELETE route removes a task by its already-parsed ObjectId
There are some other details in this code worth calling out as well:
- find() returns a cursor, not an array, so we chain .toArray() after sorting to actually get the results
- findOneAndUpdate with returnDocument: 'after' gives you the updated document immediately
- We return proper HTTP status codes: 201 for create, 204 for delete, and 400 or 404 where appropriate
- Any unexpected errors are passed to next(err), so they can be handled in one place instead of inside every route
Step 6: Wire Everything Together in the Server
At this point, all the pieces are in place. We have validation, clean routes, and a working database connection. Now we just need to wire everything together and actually start the server. Create a server.js file. This is the entry point of the app, where everything comes together:
require('dotenv').config();
const path = require('path');
const express = require('express');
const helmet = require('helmet');
const { connectDB, closeDB } = require('./db/connect');
const tasksRouter = require('./routes/tasks');
if (!process.env.MONGO_URI) {
console.error('Fatal: MONGO_URI is not set. Copy .env.example to .env and fill it in.');
process.exit(1);
}
const PORT = Number(process.env.PORT) || 3000;
const app = express();
app.use(helmet());
app.use(express.json({ limit: '100kb' }));
app.use(express.static(path.join(__dirname, 'public')));
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'task-manager-with-categories' });
});
app.use('/tasks', tasksRouter);
app.use((req, res) => {
res.status(404).json({ error: 'not found' });
});
app.use((err, req, res, next) => {
if (err.type === 'entity.too.large') {
return res.status(413).json({ error: 'payload too large' });
}
if (err.type === 'entity.parse.failed') {
return res.status(400).json({ error: 'invalid JSON body' });
}
console.error(err);
res.status(500).json({ error: 'internal server error' });
});
async function start() {
try {
await connectDB();
const server = app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
const shutdown = () => {
console.log('\nShutting down gracefully...');
server.close(async () => {
await closeDB();
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000).unref();
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
} catch (err) {
console.error('Failed to start server:', err);
process.exit(1);
}
}
if (require.main === module) {
start();
}
module.exports = { app, start };
This file ties everything together in a clean way. It checks that your environment variables are set before anything runs, applies basic security with helmet, and limits incoming JSON so your app doesn't accept huge payloads by mistake. It also serves your public/ folder, so your frontend can live in the same app without needing a separate server.
All your routes are mounted under /tasks, and anything that doesn't match returns a clean 404. Errors are handled in one place instead of being repeated inside every route, which keeps things consistent and easier to maintain. The database connection is established before the server starts listening, so you never accept requests before you're ready, and shutdown is handled gracefully, so connections are closed properly.
Now let's fire it up and make sure everything is working by running npm run dev. If everything is wired up correctly, you should see something like this:
MongoDB connected (db: taskmanager)
Server running on http://localhost:3000
At this point, everything is wired up and running! We now have a fully working API, so let's test it.
Step 7: Test the API Endpoints
Before building a UI, it's worth confirming that each endpoint works on its own. This makes debugging much easier, since you can quickly tell whether an issue is coming from the API or the frontend. We'll use Postman (or any HTTP client) and run a few requests in order. Each one builds on the data created by the previous step.
Create a task: Send a POST request with a title, description, and category. If everything works, the API returns 201 Created with the new task and its generated _id.
POST http://localhost:3000/tasks
Content-Type: application/json
{
"title": "Write GeeksForGeeks article",
"description": "First draft by Friday",
"category": "Work"
}
Create a few more: Run the same POST request with different bodies, so you have enough data to test listing and filtering. After this, you'll have a small dataset to query.
{ "title": "Go for a run", "category": "Personal" }
{ "title": "Read MongoDB docs", "category": "Study" }
{ "title": "Buy groceries" } // category defaults to "Other"
List all tasks: This returns all tasks, sorted by newest first.
GET http://localhost:3000/tasks
Filter by category: This returns only the Work tasks. The index you added earlier is what keeps this query fast as your data grows.
GET http://localhost:3000/tasks?category=Work
Update a task: Use the _id from one of the tasks you just created (you can copy it from the POST response or the list endpoint). This returns the updated task. You only need to send the fields you want to change, so partial updates stay simple.
PUT http://localhost:3000/tasks/<paste-task-id-here>
Content-Type: application/json
{ "completed": true }
Delete a task: Use the _id from the task you want to delete. This returns 204 No Content. If you try to fetch the same task again, you'll get a 404 with "task not found".
DELETE http://localhost:3000/tasks/<paste-task-id-here>
At this point, you've confirmed that all routes behave as expected. The API is doing its job, so now we can move on to building a UI on top of it.
Step 8: Add a Frontend to Interact With the API
Postman is great for verifying the API, but it'd be nice to see how it works on an actual website, so let's add some frontend code to our application.
Instead of pasting large HTML and CSS files here, I've added the code into this GitHub repo. Head over there and copy the contents of index.html, styles.css, and favicon.svg into your public/ folder in your project root. Save the changes and then head over to localhost:3000. Then paste the code below into the app.js file:
const form = document.getElementById('task-form');
const titleEl = document.getElementById('title');
const descEl = document.getElementById('description');
const catEl = document.getElementById('category');
const errEl = document.getElementById('form-error');
const listEl = document.getElementById('task-list');
const emptyEl = document.getElementById('empty');
const filterEl = document.getElementById('filter');
const countEl = document.getElementById('task-count');
async function api(method, path, body) {
const res = await fetch(path, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined
});
if (res.status === 204) return null;
const data = await res.json();
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
return data;
}
const fetchTasks = (category) =>
api('GET', '/tasks' + (category ? `?category=${encodeURIComponent(category)}` : ''));
const createTask = (body) => api('POST', '/tasks', body);
const updateTask = (id, body) => api('PUT', `/tasks/${id}`, body);
const deleteTask = (id) => api('DELETE', `/tasks/${id}`);
function updateCount(tasks) {
const active = tasks.filter((t) => !t.completed).length;
const total = tasks.length;
if (total === 0) {
countEl.textContent = '';
return;
}
const scope = filterEl.value ? ` in ${filterEl.value}` : '';
countEl.textContent = `${total} tasks${scope} · ${active} active`;
}
function renderTask(task) {
const li = document.createElement('li');
li.className = 'task' + (task.completed ? ' completed' : '');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = task.completed;
checkbox.addEventListener('change', () =>
updateTask(task._id, { completed: checkbox.checked }).then(refresh)
);
const title = document.createElement('div');
title.textContent = task.title;
const badge = document.createElement('span');
badge.textContent = task.category;
const del = document.createElement('button');
del.textContent = 'Delete';
del.onclick = () => deleteTask(task._id).then(refresh);
li.append(checkbox, title, badge, del);
return li;
}
function render(tasks) {
listEl.innerHTML = '';
emptyEl.classList.toggle('hidden', tasks.length > 0);
for (const task of tasks) {
listEl.appendChild(renderTask(task));
}
updateCount(tasks);
}
async function refresh() {
try {
const tasks = await fetchTasks(filterEl.value);
render(tasks);
} catch (e) {
errEl.textContent = e.message;
}
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
errEl.textContent = '';
const title = titleEl.value.trim();
if (!title) {
errEl.textContent = 'Title is required.';
return;
}
try {
await createTask({
title,
description: descEl.value.trim(),
category: catEl.value
});
form.reset();
await refresh();
} catch (e) {
errEl.textContent = e.message;
}
});
filterEl.addEventListener('change', refresh);
refresh();
The JavaScript code is where everything connects back to the API you built earlier. Each function maps to one of your routes, which keeps the frontend easy to follow. All requests go through a single api() helper too, so you only handle errors in one place instead of repeating the same logic everywhere.
Filtering works by passing a query string like ?category=Work, which ties directly into the backend logic you already added. After creating, updating, or deleting a task, the app fetches the latest list again so the UI stays in sync. The task counter is a small detail, but it helps the app feel more alive while you use it.
Now restart your server with npm run dev, then open http://localhost:3000 to try the Task Manager. Add a few tasks, update them, switch between categories, and delete some too. At this point, the frontend and backend are fully connected and working together end to end.
Summary
Congratulations! You've successfully built a complete task manager API from scratch using Node.js, Express, and MongoDB. You handled validation, kept your routes small, and wired everything together into a working system. More importantly, you've stayed close to how MongoDB actually works instead of hiding it behind abstractions. From here, you can start evolving the app to make it production-ready.
Key Takeaways
- Using middleware and centralizing validation keeps your code clean and predictable.
- The native MongoDB driver gives you control without unnecessary abstraction.
- A well-structured API is easy to extend into a production-ready app.
FAQs
Do I Need Mongoose to Build a MongoDB API?
No. This tutorial uses the native MongoDB driver, which gives you more control and keeps things lightweight. You can add Mongoose later if you want schema abstractions.
How Do I Validate Data Without a Schema?
You can centralize validation in helper functions (such as buildTaskDocument), ensuring that every route enforces the same rules before writing to the database.
What Happens if I Pass an Invalid ObjectId?
The middleware rejects it early with a 400 response, preventing MongoDB from throwing confusing errors.

