Design a Task Manager REST API
Build a REST API with Node.js and Express using proper REST conventions.
Introduction
30 Second Summary
Every time you open a mobile app, it talks to a server through an API. The quality of that API determines whether the app feels snappy and reliable or buggy and confusing.
In this project, you will design and build a Task Manager REST API from scratch using Node.js and Express. You'll learn the conventions that make APIs predictable, consistent, and easy for any frontend to consume.
What You'll Build
A fully functional Task Manager API with CRUD endpoints, input validation, and structured error handling that follows REST best practices.
By the end of this project, you'll have:
- A running Express.js server with versioned API endpoints that use correct HTTP methods and status codes.
- A layered project architecture (routes, controllers, services, middleware) that mirrors real production backends.
- Input validation middleware that rejects bad requests with helpful, machine-readable error messages.
- Secret Mission: Add filtering by status/priority and pagination to your GET endpoint.
Are there any prerequisites?
Basic programming knowledge (variables, functions, objects, arrays) is all you need. Familiarity with Python or Dart helps, but no JavaScript experience is required.
Set Up Node.js and Initialize the Project
Before you can build an API, you need a runtime to execute your JavaScript code outside the browser. Node.js is that runtime, and it comes bundled with npm (Node Package Manager) for installing libraries like Express.
In this step, you'll install Node.js, create your project directory, install Express, and set up a clean folder structure that separates your code by responsibility.
In this step, get ready to:
- Install Node.js 24 LTS and verify it works.
- Initialize a new project and install Express.
- Create the layered folder structure for your API.
Install Node.js 24 LTS
Node.js lets you run JavaScript on your computer (outside a web browser). It also includes npm, the package manager you'll use to install Express and any other libraries.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Check if Node.js is already installed by running:
node --version
Look at what your terminal prints and pick the tab below that matches your result.
✔️ I see version 24 or higher
You should see something like v24.18.0 (or any v24.x.x number).
Node.js is ready. Move on to the next command below.
ⓧ I see an older version
If you see a version lower than v24 (for example v20.x.x or v22.x.x), you need to upgrade.
- Go to https://nodejs.org/en/download/ in your browser.
- Download the LTS installer (v24.18.0 or later).
- Run the installer and accept the default options.
- Close and reopen your terminal, then run node --version again to confirm it shows v24 or higher.
Why close and reopen the terminal?
Your terminal caches the path to programs when it starts. Installing a new version of Node.js updates that path, but your current terminal session still points to the old one. Reopening forces it to pick up the new version.
ⓧ Command not found
Node.js is not installed yet. Let's fix that.
- Go to https://nodejs.org/en/download/ in your browser.
- Download the LTS installer (v24.18.0 or later).
- Run the installer and accept the default options.
- Close and reopen your terminal, then run node --version again to confirm it shows v24 or higher.
The installer includes npm
The Node.js installer bundles npm automatically. You do not need to install it separately.
- Confirm npm is also available by running:
npm --version
You should see a version number (like 11.16.0). Any number means npm is ready.
npm command not found?
If node --version works but npm --version does not, the Node.js installer may not have finished correctly. Rerun the installer from nodejs.org, making sure to leave all checkboxes at their defaults.
Help me fix npm on my system
Create the project and install Express
With Node.js confirmed, you can create your project directory and install Express, the web framework that handles HTTP requests for your API.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir task-api
cd task-api
- Initialize a new Node.js project by running:
npm init -y
What does npm init -y do?
This creates a package.json file with default values. The -y flag skips all the prompts (project name, version, description) and accepts defaults for everything.
Think of package.json as your project's ID card. It tracks the project name, scripts you can run, and which libraries it depends on.
- Install Express by running:
npm install express
You should see output mentioning packages added with no errors.
Seeing permission errors?
On macOS or Linux, if you see EACCES errors, do NOT use sudo. Instead, make sure you ran cd ~/Desktop first. Permission errors often mean you're trying to install in a system directory you don't own.
Help me fix npm permission errors
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the task-api folder, then click Open.
You should see package.json and a node_modules folder in the VS Code sidebar.
- Click on package.json in the sidebar to open it.
- Select all the text (Cmd+A on macOS, Ctrl+A on Windows) and replace it with the following:
{
"name": "task-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"express": "^5.2.1"
}
}
What does this configuration do?
"type": "module" tells Node.js to use modern import/export syntax instead of the older require() style.
The dev script uses node --watch, which automatically restarts your server whenever you save a file. No extra tools needed.
- Save package.json with Cmd+S (macOS) or Ctrl+S (Windows).
✔️ Awesome, I've got everything!
Great. Double check you've saved package.json and move on to the next substep.
ⓧ I'd like to double check the full code
Here is the complete package.json file you should have:
{
"name": "task-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"express": "^5.2.1"
}
}
Create the project folder structure
A well-organized API separates code by responsibility. You will create folders for routes, controllers, services, middleware, and data so each layer has its own home.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
- Create all the folders by running:
macOS
mkdir -p src/routes src/controllers src/services src/middleware src/data
Windows
mkdir src/routes, src/controllers, src/services, src/middleware, src/data
What is this folder structure for?
- routes/ maps URLs to handler functions.
- controllers/ handles HTTP request/response logic.
- services/ contains business logic (the actual work).
- middleware/ holds reusable functions that run before or after your routes (like validation).
- data/ stores your in-memory data (standing in for a real database).
- Confirm the folders exist by running:
ls src
You should see controllers, data, middleware, routes, and services listed in the output.
Seeing 'No such file or directory'?
Make sure your terminal is inside the task-api folder. Check by running pwd (macOS) or cd (Windows). The path should end with task-api. If not, navigate there first.
Help me find my project folder
Your project skeleton is ready. Next up, you'll create the Express server and serve your first API response.
Create the Express Server and API Root
Your project folder is ready with all the directories in place. Now it's time to write the code that brings your API to life.
Every REST API needs a server that listens for incoming requests and routes them to the right handler. In this step, you'll create two files that form the core of your API: the app configuration and the server entry point.
In this step, get ready to:
- Create the Express app with JSON parsing and a root endpoint.
- Create a server file that starts the app on port 3000.
- Test your running API in the browser.
Create the Express app module
Express applications start with an app object that holds all your routes and middleware. You'll define this in its own file so you can import it into the server (and later into tests) without starting the server.
- In VS Code, create a new file called app.js inside the src/ folder.
- Add the following code to src/app.js:
import express from 'express';
const app = express();
// Parse incoming JSON request bodies
app.use(express.json());
// Root endpoint to confirm the API is running
app.get('/api/v1', (req, res) => {
res.json({ message: 'Task Manager API v1' });
});
export default app;
What does this code do?
import express from 'express' brings in the Express library using ES module syntax (enabled by the "type": "module" setting in your package.json).
app.use(express.json()) tells Express to automatically parse JSON in request bodies. Without this, your API couldn't read data sent by clients.
The /api/v1 endpoint is a health check that confirms your API is reachable. The v1 prefix is a versioning convention that lets you release breaking changes under /api/v2 later without disrupting existing clients.
- Save src/app.js.
Seeing a red underline on the import?
Make sure your package.json contains "type": "module". Without it, Node.js treats .js files as CommonJS and won't recognize import statements.
Still stuck? Help me fix an import error in my Express app
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
import express from 'express';
const app = express();
app.use(express.json());
app.get('/api/v1', (req, res) => {
res.json({ message: 'Task Manager API v1' });
});
export default app;
Create the server entry point
The server file has one job: import the app and start listening for connections. Keeping the server startup separate from the app config is a common pattern that makes testing easier.
- Create a new file called server.js inside the src/ folder.
- Add the following code to src/server.js:
import app from './app.js';
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Task API running at http://localhost:${PORT}`);
});
What does this code do?
import app from './app.js' pulls in the configured Express app you just created.
app.listen(PORT, callback) starts a TCP server on port 3000 and runs the callback once it's ready. The console message gives you a clickable URL to open in your browser.
- Save src/server.js.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
import app from './app.js';
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Task API running at http://localhost:${PORT}`);
});
Test your running API
Your two files are ready. Time to start the server and confirm everything is wired up correctly.
- Open a terminal in VS Code by pressing Cmd+` (macOS) or Ctrl+` (Windows).
- Start the server by running this command:
node src/server.js
- You should see this output in your terminal:
Task API running at http://localhost:3000
- Open your browser and navigate to http://localhost:3000/api/v1.
- You should see a JSON response: {"message":"Task Manager API v1"}.
Why /api/v1 and not just /?
Prefixing routes with /api/v1 is a REST convention that separates your API from other content (like a frontend) and lets you version your API independently. When you need to introduce breaking changes, you add /api/v2 endpoints while keeping v1 alive for existing clients.
Server not starting?
If you see Cannot find module, make sure both files are saved inside the src/ folder (not the project root).
If port 3000 is already in use, stop any other processes using that port and try again.
Still stuck? Help me debug my Express server startup
Your Express server is running and responding to requests. Next up, you'll build the task endpoints with full CRUD operations following REST conventions.
Build the Task Endpoints with REST Conventions
Your server is running and the project folders are ready. Now it's time to fill those folders with real code that handles task data.
In this step, you'll build a complete set of CRUD endpoints (Create, Read, Update, Delete) using a layered architecture. Each layer has one job: routes map URLs to controllers, controllers handle HTTP details, and services contain the business logic.
In this step, get ready to:
- Create seed data so you have tasks to work with immediately.
- Build a service layer with all the CRUD methods your API needs.
- Wire up a controller and routes so HTTP requests flow through your layered architecture.
Create the seed data store
Before your API can return tasks, it needs data to serve. You'll create an in-memory array of task objects that acts as your database for now.
- Create a new file called store.js inside your src/data/ folder, and add the following code:
const tasks = [
{
id: '1',
title: 'Learn REST API design',
description: 'Understand resource naming, HTTP methods, and status codes',
status: 'in-progress',
priority: 'high',
createdAt: '2026-07-07T10:00:00.000Z'
},
{
id: '2',
title: 'Build Express server',
description: 'Set up a Node.js project with Express',
status: 'completed',
priority: 'high',
createdAt: '2026-07-07T09:00:00.000Z'
},
{
id: '3',
title: 'Connect Flutter app',
description: 'Wire up the Flutter frontend to the API',
status: 'todo',
priority: 'medium',
createdAt: '2026-07-07T11:00:00.000Z'
}
];
export default tasks;
What does this code do?
This file defines an array of three task objects, each with a unique id, a title, description, status, priority, and a createdAt timestamp.
The export default tasks line makes the array available to other files using ES module imports. This in-memory store keeps the focus on API design rather than database setup.
- Save src/data/store.js.
- You won't see a change yet. The next file (the service layer) will import and use this data.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
const tasks = [
{
id: '1',
title: 'Learn REST API design',
description: 'Understand resource naming, HTTP methods, and status codes',
status: 'in-progress',
priority: 'high',
createdAt: '2026-07-07T10:00:00.000Z'
},
{
id: '2',
title: 'Build Express server',
description: 'Set up a Node.js project with Express',
status: 'completed',
priority: 'high',
createdAt: '2026-07-07T09:00:00.000Z'
},
{
id: '3',
title: 'Connect Flutter app',
description: 'Wire up the Flutter frontend to the API',
status: 'todo',
priority: 'medium',
createdAt: '2026-07-07T11:00:00.000Z'
}
];
export default tasks;
Build the service layer
The service layer holds your business logic. It knows how to find, create, update, and remove tasks without caring about HTTP requests or responses.
- Create a new file called taskService.js inside your src/services/ folder, and add the following code:
import tasks from '../data/store.js';
let nextId = 4;
export const taskService = {
getAll() {
return tasks;
},
getById(id) {
return tasks.find(task => task.id === id);
},
create(data) {
const newTask = {
id: String(nextId++),
title: data.title,
description: data.description || '',
status: data.status || 'todo',
priority: data.priority || 'medium',
createdAt: new Date().toISOString()
};
tasks.push(newTask);
return newTask;
},
update(id, data) {
const index = tasks.findIndex(task => task.id === id);
if (index === -1) return null;
const updatedTask = { ...tasks[index], ...data };
tasks[index] = updatedTask;
return updatedTask;
},
remove(id) {
const index = tasks.findIndex(task => task.id === id);
if (index === -1) return false;
tasks.splice(index, 1);
return true;
}
};
What does this code do?
- getAll() returns every task in the store.
- getById(id) searches the array for a matching task and returns undefined if none is found.
- create(data) builds a new task object with an auto-incrementing id and sensible defaults, then pushes it to the array.
- update(id, data) merges the provided fields onto the existing task using the spread operator. It returns null if the task doesn't exist.
- remove(id) splices the task out of the array and returns false if it wasn't found.
Why does update use PATCH semantics?
The service's update method merges only the fields you provide, leaving other fields untouched. This matches HTTP PATCH, which means "partial update."
The alternative (HTTP PUT) requires sending the entire resource every time, even if you only changed one field. PATCH is more ergonomic for clients because they only send what changed.
- Save src/services/taskService.js.
- You won't see a change yet. The controller (coming next) will call these methods.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import tasks from '../data/store.js';
let nextId = 4;
export const taskService = {
getAll() {
return tasks;
},
getById(id) {
return tasks.find(task => task.id === id);
},
create(data) {
const newTask = {
id: String(nextId++),
title: data.title,
description: data.description || '',
status: data.status || 'todo',
priority: data.priority || 'medium',
createdAt: new Date().toISOString()
};
tasks.push(newTask);
return newTask;
},
update(id, data) {
const index = tasks.findIndex(task => task.id === id);
if (index === -1) return null;
const updatedTask = { ...tasks[index], ...data };
tasks[index] = updatedTask;
return updatedTask;
},
remove(id) {
const index = tasks.findIndex(task => task.id === id);
if (index === -1) return false;
tasks.splice(index, 1);
return true;
}
};
Create the controller, routes, and wire them up
The controller translates between HTTP and your service layer. It reads the request, calls the appropriate service method, and sends back a response with the correct status code.
- Create a new file called taskController.js inside your src/controllers/ folder, and add the following code:
import { taskService } from '../services/taskService.js';
export const taskController = {
// GET /api/v1/tasks - returns all tasks
getAllTasks(req, res) {
const tasks = taskService.getAll();
res.json({ data: tasks });
},
// GET /api/v1/tasks/:id - returns one task or 404
getTaskById(req, res, next) {
const task = taskService.getById(req.params.id);
if (!task) {
const error = new Error('Task not found');
error.statusCode = 404;
error.code = 'RESOURCE_NOT_FOUND';
return next(error);
}
res.json({ data: task });
},
// POST /api/v1/tasks - creates a new task, returns 201
createTask(req, res) {
const newTask = taskService.create(req.body);
res.status(201).json({ data: newTask });
},
// PATCH /api/v1/tasks/:id - updates a task or 404
updateTask(req, res, next) {
const updatedTask = taskService.update(req.params.id, req.body);
if (!updatedTask) {
const error = new Error('Task not found');
error.statusCode = 404;
error.code = 'RESOURCE_NOT_FOUND';
return next(error);
}
res.json({ data: updatedTask });
},
// DELETE /api/v1/tasks/:id - deletes a task, returns 204
deleteTask(req, res, next) {
const deleted = taskService.remove(req.params.id);
if (!deleted) {
const error = new Error('Task not found');
error.statusCode = 404;
error.code = 'RESOURCE_NOT_FOUND';
return next(error);
}
res.status(204).end();
}
};
What do the status codes mean?
- 200 (OK) is the default for successful GET and PATCH responses.
- 201 (Created) tells the client a new resource was successfully created.
- 204 (No Content) confirms the deletion succeeded. The response body is empty because the resource no longer exists.
- 404 (Not Found) means the requested task doesn't exist in the store.
Notice how errors are passed to next(error) instead of being handled inline. This pushes error formatting to a centralized handler you'll build in a later step.
- Save src/controllers/taskController.js.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import { taskService } from '../services/taskService.js';
export const taskController = {
getAllTasks(req, res) {
const tasks = taskService.getAll();
res.json({ data: tasks });
},
getTaskById(req, res, next) {
const task = taskService.getById(req.params.id);
if (!task) {
const error = new Error('Task not found');
error.statusCode = 404;
error.code = 'RESOURCE_NOT_FOUND';
return next(error);
}
res.json({ data: task });
},
createTask(req, res) {
const newTask = taskService.create(req.body);
res.status(201).json({ data: newTask });
},
updateTask(req, res, next) {
const updatedTask = taskService.update(req.params.id, req.body);
if (!updatedTask) {
const error = new Error('Task not found');
error.statusCode = 404;
error.code = 'RESOURCE_NOT_FOUND';
return next(error);
}
res.json({ data: updatedTask });
},
deleteTask(req, res, next) {
const deleted = taskService.remove(req.params.id);
if (!deleted) {
const error = new Error('Task not found');
error.statusCode = 404;
error.code = 'RESOURCE_NOT_FOUND';
return next(error);
}
res.status(204).end();
}
};
Now you need a router to map HTTP methods and URL patterns to these controller functions.
- Create a new file called taskRoutes.js inside your src/routes/ folder, and add the following code:
import express from 'express';
import { taskController } from '../controllers/taskController.js';
const router = express.Router();
// GET /api/v1/tasks
router.get('/', taskController.getAllTasks);
// GET /api/v1/tasks/:id
router.get('/:id', taskController.getTaskById);
// POST /api/v1/tasks
router.post('/', taskController.createTask);
// PATCH /api/v1/tasks/:id
router.patch('/:id', taskController.updateTask);
// DELETE /api/v1/tasks/:id
router.delete('/:id', taskController.deleteTask);
export default router;
What does this code do?
express.Router() creates a mini-app that handles a specific group of routes. Each line maps an HTTP method (GET, POST, PATCH, DELETE) to the matching controller function.
The paths here are relative. When you mount this router at /api/v1/tasks in your main app, a GET / here becomes GET /api/v1/tasks to the outside world.
- Save src/routes/taskRoutes.js.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import express from 'express';
import { taskController } from '../controllers/taskController.js';
const router = express.Router();
router.get('/', taskController.getAllTasks);
router.get('/:id', taskController.getTaskById);
router.post('/', taskController.createTask);
router.patch('/:id', taskController.updateTask);
router.delete('/:id', taskController.deleteTask);
export default router;
The last piece is mounting the router in your main app file so Express knows to send requests at /api/v1/tasks to your new router.
- Switch back to src/app.js in VS Code.
- Find this line at the top:
import express from 'express';
- Add this import directly below it:
import taskRoutes from './routes/taskRoutes.js';
- Now find this line near the bottom (just above export default app):
app.get('/api/v1', (req, res) => {
res.json({ message: 'Task Manager API v1' });
});
- Add this line directly below it to mount the task router:
app.use('/api/v1/tasks', taskRoutes);
What does app.use do here?
app.use('/api/v1/tasks', taskRoutes) tells Express to forward any request starting with /api/v1/tasks to your task router. The router then matches the rest of the path (/ or /:id) against its own routes.
- Save src/app.js.
- If your server is running with npm run dev, it auto-restarts on save. Otherwise, stop the server with Ctrl+C and restart it by running:
npm run dev
- Open your browser and visit http://localhost:3000/api/v1/tasks.
- You should see a JSON response containing all three seed tasks wrapped in a data property.
Seeing an error instead of tasks?
- Check the terminal for import errors. A typo in a file path (like ./routes/taskRoutes missing the .js extension) will crash the server.
- Make sure store.js uses export default tasks and taskService.js uses export const taskService. Mismatched exports and imports are the most common cause.
- Confirm you're visiting /api/v1/tasks (plural) not /api/v1/task.
Help me debug my task API endpoint
✔️ Awesome, I've got everything!
Great. Double check you've saved src/app.js with both the new import and the app.use line.
ⓧ I'd like to double check the full code
import express from 'express';
import taskRoutes from './routes/taskRoutes.js';
const app = express();
app.use(express.json());
app.get('/api/v1', (req, res) => {
res.json({ message: 'Task Manager API v1' });
});
app.use('/api/v1/tasks', taskRoutes);
export default app;
Why plural nouns for resource URLs?
REST convention uses plural nouns (/tasks) because the endpoint represents a collection of resources. A GET to /tasks returns the collection. A GET to /tasks/:id returns one item from that collection.
Using singular nouns (/task/1) is a common mistake that makes APIs feel inconsistent. Stick with plurals for all resource endpoints.
Your API now serves task data through a clean, layered architecture. Next up, you'll add input validation middleware so the API rejects bad data before it ever reaches the service layer.
Add Input Validation Middleware
Your CRUD endpoints are working. Any client can create, read, update, and delete tasks. But what happens if someone sends a request with no title, or with a status like "banana"?
Right now, the API blindly accepts whatever data it receives. In this step, you'll build input validation middleware that catches bad data before it reaches your service layer. The middleware returns helpful, machine-readable error messages that tell the client exactly which fields are wrong.
In this step, get ready to:
- Create a validation middleware that rejects requests with missing or invalid fields.
- Add a separate validator for partial updates (PATCH requests).
- Wire the validators into your routes and test them with invalid data.
Create the validateCreateTask middleware
Middleware in Express is a function that runs before your controller. It can inspect the request, reject it early with an error, or pass it along to the next function in the chain.
- Create a new file called validate.js inside your src/middleware/ folder.
- Add the following code to define your allowed values and the creation validator:
const VALID_STATUSES = ['todo', 'in-progress', 'completed'];
const VALID_PRIORITIES = ['low', 'medium', 'high'];
export function validateCreateTask(req, res, next) {
const errors = [];
// Check that title exists and is a string
if (!req.body.title || typeof req.body.title !== 'string') {
errors.push({ field: 'title', message: 'Title is required and must be a string' });
} else if (req.body.title.trim().length === 0) {
errors.push({ field: 'title', message: 'Title cannot be empty' });
}
// Validate status only if provided
if (req.body.status && !VALID_STATUSES.includes(req.body.status)) {
errors.push({ field: 'status', message: `Status must be one of: ${VALID_STATUSES.join(', ')}` });
}
// Validate priority only if provided
if (req.body.priority && !VALID_PRIORITIES.includes(req.body.priority)) {
errors.push({ field: 'priority', message: `Priority must be one of: ${VALID_PRIORITIES.join(', ')}` });
}
// If any errors were found, return a 400 with details
if (errors.length > 0) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request body',
details: errors
}
});
}
next();
}
What does this code do?
VALID_STATUSES and VALID_PRIORITIES define the only acceptable values for those fields. Any value outside these arrays gets rejected.
The function collects errors into an array instead of stopping at the first problem. This way, the client gets all the issues at once and can fix them in a single retry. If no errors are found, next() passes the request along to the controller.
What is middleware?
Middleware functions sit between the incoming request and your controller. They can read or modify the request, send a response early (rejecting bad data), or call next() to hand off to the next function in the chain.
You can stack multiple middleware on a single route. The request flows through each one in order until something sends a response or the controller runs.
- Save src/middleware/validate.js.
You won't see a visible change yet. The next chunk adds the update validator, and then you'll wire both into your routes to test.
Seeing a syntax error?
Make sure you're using backtick characters (`) for the template literal strings inside the error messages, not regular single quotes.
Also check that the export keyword is present before function. Without it, other files cannot import the validator.
Help me fix a syntax error in my validation middleware.
Add the validateUpdateTask function
The create validator requires a title. But for PATCH (partial update) requests, the client only sends the fields they want to change. A missing title in a PATCH is perfectly fine. It just means the client is not updating the title.
- In src/middleware/validate.js, add the following function below validateCreateTask:
export function validateUpdateTask(req, res, next) {
const errors = [];
// Only validate title if it was provided in the request
if (req.body.title !== undefined) {
if (typeof req.body.title !== 'string' || req.body.title.trim().length === 0) {
errors.push({ field: 'title', message: 'Title must be a non-empty string' });
}
}
// Validate status only if provided
if (req.body.status && !VALID_STATUSES.includes(req.body.status)) {
errors.push({ field: 'status', message: `Status must be one of: ${VALID_STATUSES.join(', ')}` });
}
// Validate priority only if provided
if (req.body.priority && !VALID_PRIORITIES.includes(req.body.priority)) {
errors.push({ field: 'priority', message: `Priority must be one of: ${VALID_PRIORITIES.join(', ')}` });
}
// If any errors were found, return a 400 with details
if (errors.length > 0) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request body',
details: errors
}
});
}
next();
}
How is this different from validateCreateTask?
The key difference is the title check. In validateCreateTask, a missing title is an error. In validateUpdateTask, the title is only validated if it's actually present in the request body (req.body.title !== undefined).
This is the difference between PUT (replace the entire resource) and PATCH (change only the provided fields). Your API uses PATCH, so partial updates are expected.
- Save src/middleware/validate.js.
You won't see a change yet. The next chunk wires both validators into your routes.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
const VALID_STATUSES = ['todo', 'in-progress', 'completed'];
const VALID_PRIORITIES = ['low', 'medium', 'high'];
export function validateCreateTask(req, res, next) {
const errors = [];
if (!req.body.title || typeof req.body.title !== 'string') {
errors.push({ field: 'title', message: 'Title is required and must be a string' });
} else if (req.body.title.trim().length === 0) {
errors.push({ field: 'title', message: 'Title cannot be empty' });
}
if (req.body.status && !VALID_STATUSES.includes(req.body.status)) {
errors.push({ field: 'status', message: `Status must be one of: ${VALID_STATUSES.join(', ')}` });
}
if (req.body.priority && !VALID_PRIORITIES.includes(req.body.priority)) {
errors.push({ field: 'priority', message: `Priority must be one of: ${VALID_PRIORITIES.join(', ')}` });
}
if (errors.length > 0) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request body',
details: errors
}
});
}
next();
}
export function validateUpdateTask(req, res, next) {
const errors = [];
if (req.body.title !== undefined) {
if (typeof req.body.title !== 'string' || req.body.title.trim().length === 0) {
errors.push({ field: 'title', message: 'Title must be a non-empty string' });
}
}
if (req.body.status && !VALID_STATUSES.includes(req.body.status)) {
errors.push({ field: 'status', message: `Status must be one of: ${VALID_STATUSES.join(', ')}` });
}
if (req.body.priority && !VALID_PRIORITIES.includes(req.body.priority)) {
errors.push({ field: 'priority', message: `Priority must be one of: ${VALID_PRIORITIES.join(', ')}` });
}
if (errors.length > 0) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request body',
details: errors
}
});
}
next();
}
Wire validation into routes and test
The validators exist but they're not connected to anything yet. You need to import them into your routes file and place them in front of the controller functions for POST and PATCH.
- Open src/routes/taskRoutes.js and replace its entire contents with the following:
import express from 'express';
import { taskController } from '../controllers/taskController.js';
import { validateCreateTask, validateUpdateTask } from '../middleware/validate.js';
const router = express.Router();
router.get('/', taskController.getAllTasks);
router.get('/:id', taskController.getTaskById);
// Validation middleware runs before the controller
router.post('/', validateCreateTask, taskController.createTask);
router.patch('/:id', validateUpdateTask, taskController.updateTask);
router.delete('/:id', taskController.deleteTask);
export default router;
What changed here?
Two things changed. First, you imported validateCreateTask and validateUpdateTask from your new middleware file.
Second, the POST and PATCH routes now pass the validator as a second argument before the controller. Express calls these functions left to right. If the validator sends a 400 response, the controller never runs.
- Save src/routes/taskRoutes.js.
- Start (or restart) your server by running this command:
node src/server.js
- Test the validation by sending a POST request with no title. Open a new terminal and run:
curl -X POST http://localhost:3000/api/v1/tasks -H "Content-Type: application/json" -d '{"description": "Missing a title"}'
You should see a structured error response like this:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": [
{
"field": "title",
"message": "Title is required and must be a string"
}
]
}
}
- Now test with an invalid status value by running:
curl -X POST http://localhost:3000/api/v1/tasks -H "Content-Type: application/json" -d '{"title": "Test task", "status": "banana"}'
You should see a 400 response with a details array mentioning that status must be one of the valid options.
Getting an empty response or connection refused?
Make sure your server is running in a separate terminal window. The curl command needs to run in a different terminal than the one running node src/server.js.
If you see an HTML response instead of JSON, check that you included the -H "Content-Type: application/json" header in your curl command. Without it, Express may not parse the body correctly.
Help me troubleshoot my validation middleware not working.
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
import express from 'express';
import { taskController } from '../controllers/taskController.js';
import { validateCreateTask, validateUpdateTask } from '../middleware/validate.js';
const router = express.Router();
router.get('/', taskController.getAllTasks);
router.get('/:id', taskController.getTaskById);
router.post('/', validateCreateTask, taskController.createTask);
router.patch('/:id', validateUpdateTask, taskController.updateTask);
router.delete('/:id', taskController.deleteTask);
export default router;
Your API now rejects bad data with clear, field-level error messages. Next up, you'll add a centralized error handler so that all errors across your entire API follow the same consistent format.
Add Centralized Error Handling
Your controllers already know how to detect errors and pass them along with next(error). But right now, nothing is catching those errors at the end of the line.
In this step, you'll create a single error handler that catches every error in your API and formats it into a consistent response shape. This means any client consuming your API always knows exactly what an error looks like.
In this step, get ready to:
- Create a centralized error handler middleware that formats all errors consistently.
- Register the error handler in your Express app.
- Test every endpoint to verify correct status codes and error responses.
Create the error handler middleware
Express recognizes error-handling middleware by its function signature. A normal middleware takes 3 parameters, but an error handler takes 4: err, req, res, and next.
- Create a new file at src/middleware/errorHandler.js and add the following code:
export function errorHandler(err, req, res, next) {
const statusCode = err.statusCode || 500;
const code = err.code || 'INTERNAL_ERROR';
const message = err.message || 'Internal Server Error';
res.status(statusCode).json({
error: {
code,
message
}
});
}
What does this code do?
This function is a catch-all for any error passed through next(error) in your controllers.
- It reads err.statusCode (defaulting to 500 if none was set) to decide the HTTP response status.
- It reads err.code and err.message to build a consistent error envelope that clients can always parse the same way.
- Every error response from your API now has the shape { error: { code, message } } regardless of where the error originated.
- Save src/middleware/errorHandler.js.
Why centralize error handling?
Without a centralized handler, every controller would need its own error-formatting logic. If you later decided to change the error shape (adding a timestamp or request ID), you'd have to update every file.
With a single handler, you change one file and every error response updates automatically.
Register the error handler in your app
Express error handlers must be registered as the last middleware, after all routes. This ensures that any unhandled error from any route gets caught.
- In src/app.js, find the line that says:
import taskRoutes from './routes/taskRoutes.js';
- Add this import directly below it:
import { errorHandler } from './middleware/errorHandler.js';
- Now scroll down to the line that mounts your task routes:
app.use('/api/v1/tasks', taskRoutes);
- Add this line directly below it, before the export default app line:
app.use(errorHandler);
Why does order matter?
Express processes middleware in the order you register them. If you placed the error handler before your routes, it would never receive errors from those routes because Express hasn't reached them yet.
Placing it last means it's the final safety net for anything that goes wrong.
- Save src/app.js.
Seeing a module not found error?
- Check that the import path matches your file exactly: ./middleware/errorHandler.js. The .js extension is required for ES modules.
- Make sure you saved src/middleware/errorHandler.js before running the server.
Help me fix a module not found error in my Express app.
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on to testing.
ⓧ I'd like to double check the full code
Here is what src/middleware/errorHandler.js should look like:
export function errorHandler(err, req, res, next) {
const statusCode = err.statusCode || 500;
const code = err.code || 'INTERNAL_ERROR';
const message = err.message || 'Internal Server Error';
res.status(statusCode).json({
error: {
code,
message
}
});
}
Here is what src/app.js should look like:
import express from 'express';
import taskRoutes from './routes/taskRoutes.js';
import { errorHandler } from './middleware/errorHandler.js';
const app = express();
app.use(express.json());
app.get('/api/v1', (req, res) => {
res.json({ message: 'Task Manager API v1' });
});
app.use('/api/v1/tasks', taskRoutes);
app.use(errorHandler);
export default app;
Test the full API
Your API is now complete with routes, validation, and centralized error handling. Time to run through every operation and confirm each endpoint returns the correct status code.
- Start your server by running this command in the terminal:
node src/server.js
- You should see Task API running at http://localhost:3000 in the terminal output.
- Open a second terminal (keep the server running in the first one).
First, test creating a new task. A successful creation returns status 201.
- Create a task by running this command:
curl -s -X POST http://localhost:3000/api/v1/tasks -H "Content-Type: application/json" -d '{"title": "Test centralized errors", "priority": "high"}'
- You should see a JSON response with "data" containing your new task, including an auto-generated id and createdAt timestamp.
Next, retrieve all tasks. A successful retrieval returns status 200.
- Fetch all tasks by running:
curl -s http://localhost:3000/api/v1/tasks
- You should see a "data" array containing 4 tasks (the 3 seed tasks plus the one you just created).
Now test updating a task with PATCH. This returns status 200 with the updated task.
- Update task 1's status by running:
curl -s -X PATCH http://localhost:3000/api/v1/tasks/1 -H "Content-Type: application/json" -d '{"status": "completed"}'
- You should see the task with "status": "completed" in the response.
Test deleting a task. A successful delete returns status 204 with no response body.
- Delete task 3 by running:
curl -s -X DELETE http://localhost:3000/api/v1/tasks/3 -w "\nHTTP Status: %{http_code}\n"
- You should see HTTP Status: 204 with no JSON body above it.
Now test the error paths. Try to fetch a task that doesn't exist to confirm your centralized error handler formats the 404 response.
- Request a non-existent task by running:
curl -s http://localhost:3000/api/v1/tasks/999
- You should see the consistent error envelope:
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "Task not found"
}
}
Finally, test validation by sending invalid data. This triggers the validation middleware and returns status 400.
- Try to create a task with no title by running:
curl -s -X POST http://localhost:3000/api/v1/tasks -H "Content-Type: application/json" -d '{"priority": "low"}'
- You should see a VALIDATION_ERROR response with a details array listing the missing title field.
What should I see?
Every error from your API now follows a predictable pattern. Your 404 errors come through the centralized errorHandler with the { error: { code, message } } shape. Your 400 validation errors include additional details from the validation middleware.
A client consuming this API can always check for an error key to know something went wrong, then read code to handle it programmatically.
Getting an HTML error page instead of JSON?
- Make sure app.use(errorHandler) is placed after app.use('/api/v1/tasks', taskRoutes) in src/app.js.
- Confirm that the errorHandler function has exactly 4 parameters: (err, req, res, next). If you accidentally omit one, Express treats it as regular middleware and skips it.
- Restart the server after saving your changes (press Ctrl+C to stop, then run node src/server.js again).
Help me debug my Express error handler not catching errors.
Your REST API is complete. You have proper resource naming, correct HTTP methods, appropriate status codes, input validation, and a centralized error handler. This is the exact architecture you'll use when building production backends.
Secret mission
Add Filtering and Pagination
Your API returns all tasks every time. Real APIs let clients filter by status or priority, and paginate large result sets. Can you add query parameter support to your GET endpoint?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The task-api project folder on your Desktop (contains all source files and node_modules).
- Node.js installed on your system.
Keep everything running
No action needed. Choose this if you plan to keep building on this API or want to reference the code later.
- Your task-api folder stays on your Desktop, ready to use anytime.
- Node.js remains installed for future JavaScript and Node.js projects.
- To start the server again later, open your terminal, navigate to the task-api folder, and run node src/server.js.
Pause - I'll come back to this later
Stop the running server process to free up the terminal. Your files stay intact for when you return.
- Press Ctrl+C in the terminal where your server is running to stop it.
- Close the terminal window if you like. Your project files remain on your Desktop.
- When you come back, open a terminal, navigate to the task-api folder, and run node src/server.js to start up again.
Delete - I don't want to use this again
Remove all project files and optionally uninstall Node.js if you no longer need it.
- Stop the server by pressing Ctrl+C in the terminal where it is running.
- Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/task-api
On Windows, run this in PowerShell instead:
Remove-Item -Recurse -Force ~\Desktop\task-api
- To uninstall Node.js on Windows, open Settings, select Apps, find Node.js in the list, and click Uninstall.
- To uninstall Node.js on macOS (if installed via Homebrew), run:
brew uninstall node
Keep Node.js if you plan to build more
Node.js is useful for many JavaScript projects beyond this one. If you plan to continue learning web development, keep it installed.
Nice Work!
Nice Work!
You just built a production-style REST API from scratch using Node.js and Express 5. Your Task Manager API handles full CRUD operations with proper conventions that real backend teams follow every day.
Here's what you learned:
- Built a REST API with proper resource naming (/api/v1/tasks), correct HTTP methods (GET, POST, PATCH, DELETE), and appropriate status codes (200, 201, 204, 400, 404).
- Structured the project with a layered architecture: routes for HTTP wiring, controllers for request/response handling, and services for business logic.
- Implemented input validation middleware that returns field-level error details and a centralized error handler for consistent error responses.
- Secret Mission: Added filtering by status and priority plus pagination with a meta object in the response.
Ready to quiz yourself?