Build a Task Manager REST API

Build a REST API with Express.js using routing, middleware, and error handling.

Introduction

30 Second Summary

Every app you use daily communicates through APIs. When you check your to-do list, place an order, or scroll through a feed, there is an API behind the scenes receiving requests and sending back data.

In this project, you will build a Task Manager REST API from scratch using Express.js. You will create endpoints that handle creating, reading, updating, and deleting tasks, all structured with the same patterns used in production Node.js applications.

What You'll Build

A locally-running REST API that manages tasks through five endpoints, built with modular routing, custom middleware, and centralized error handling.

By the end of this project, you'll have:

  • An Express server with a JSON API running on localhost.
  • A modular router handling 5 CRUD endpoints for task management.
  • Custom middleware for request logging and input validation.
  • Secret Mission: Add filtering, search, and pagination to handle real-world data querying patterns.

Do I need any prior experience?

This project assumes basic JavaScript knowledge. You should be comfortable with functions, objects, and arrays. No prior experience with Node.js or Express is required.

Set Up Your Project

Every Express project starts with a clean folder structure. Separating routes, middleware, and core application logic into their own directories keeps the codebase navigable as it grows.

In this step, you'll create your project folder, initialize it with npm, and install Express as your only dependency.

In this step, get ready to:
  • Create the project directory with a modular folder structure.
  • Install Express as a dependency.
  • Verify the installation succeeded.
Create the project directory and initialize npm

A modular project structure means your routes live in a routes/ directory and your middleware lives in a middleware/ directory. This mirrors the pattern used in production Express apps.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and set up the directory structure by running these commands:
mkdir task-manager-api
cd task-manager-api
mkdir routes middleware

What do these directories do?

The routes/ directory holds your API endpoint definitions. The middleware/ directory holds functions that process requests before they reach your routes.

Keeping them separate makes it easy to find code and add new features without touching unrelated files.

  • Confirm the directories exist by running:
ls

You should see middleware and routes listed in the output.

Don't see the folders?

  • Make sure you ran cd task-manager-api before running mkdir routes middleware. Otherwise the subdirectories were created in the wrong place.
  • If you see a "file already exists" error, the folder was already created. Run ls to confirm and move on.

Help me troubleshoot my project folder structure.

  • Initialize your project with npm by running:
npm init -y

What does this command do?

The npm init -y command creates a package.json file with default values. This file tracks your project's name, version, and dependencies.

The -y flag accepts all defaults so you skip the interactive prompts.

You should see a message confirming that package.json was created.

Install Express

Express is the framework you'll use to define routes, handle requests, and send responses. Installing it adds the package to your project's dependencies.

  • Install Express by running:
npm install express

What does this command do?

This downloads Express from the npm registry and saves it as a dependency in your package.json. It also creates a node_modules folder containing the installed package files.

You should see output ending with "added" followed by a package count. This confirms the installation completed successfully.

Seeing a network error?

  • Check your internet connection. npm needs to download Express from its online registry.
  • If you see a permissions error, make sure you're running the command inside the task-manager-api folder where package.json exists.

Help me fix my npm install error.

Verify the installation

A quick check confirms that Express landed in your dependency tree at the expected version.

  • Check that Express appears in your dependency tree by running:
npm list express

You should see express@5.2.1 in the output. This confirms Express is installed and ready to use.

✔️ Awesome, I've got everything!

Great. Double check you've got express@5.2.1 showing and you're good to go.

ⓧ I'd like to double check the full code

Open package.json in your editor. The key fields should look like this:

{
  "name": "task-manager-api",
  "version": "1.0.0",
  "dependencies": {
    "express": "^5.2.1"
  }
}

Your file may have additional fields like description and main. That's normal. The important thing is that express appears under dependencies with version ^5.2.1.

Your project skeleton is ready. Next up, you'll create the Express server and get it responding to its first request.

Create the Express Server

Your project folder is ready with Express installed. Now it's time to write the code that actually starts your server.

Every Express application begins with a single entry point file that creates the app, configures middleware, and starts listening for incoming requests. In this step, you'll create that file and confirm your server responds to its first request.

In this step, get ready to:
  • Create app.js as the server entry point.
  • Add a health-check route that confirms the API is running.
  • Test the server with curl.
Create the server entry point

The entry point is the file Node.js runs to boot your application. It imports Express, creates the app instance, and tells it which port to listen on.

  • In Cursor, create a new file called app.js in the root of your task-manager-api folder.
  • Add the following code to app.js:
// Import the Express framework
const express = require('express');

// Create the app instance and define the port
const app = express();
const port = 3000;

// Parse incoming JSON request bodies
app.use(express.json());

// Start the server
app.listen(port, () => {
  console.log(`Task Manager API running on http://localhost:${port}`);
});

What does this code do?

  • require('express') imports the Express framework you installed in Step 1.
  • express() creates a new application instance that you'll attach routes and middleware to.
  • app.use(express.json()) is built-in middleware that automatically parses JSON request bodies so you can read them from req.body.
  • app.listen(port, callback) tells the server to start accepting connections on port 3000.
  • Save app.js.

You won't see a visible change yet. The next substep adds a route so the server has something to respond with when you test it.

Add a health-check route

A health-check route is a simple endpoint that confirms your server is alive and responding. Clients (or you, during development) can hit it to verify the API is reachable.

  • In app.js, find the line that says app.use(express.json());.
  • Add the following code below it, above app.listen:
// Health-check route to confirm the API is running
app.get('/', (req, res) => {
  res.json({ message: 'Task Manager API is running' });
});

What does this code do?

app.get('/', ...) registers a route handler for GET requests to the root path /.

res.json() sends back a JSON response with the appropriate Content-Type header automatically set. This is how Express APIs return data to clients.

  • Save app.js.

You won't see a change yet. The next substep starts the server so you can test this route.

✔️ Awesome, I've got everything!

Great. Double check you've saved app.js and move on to testing.

ⓧ I'd like to double check the full code

Here is the complete app.js file at this point:

const express = require('express');

const app = express();
const port = 3000;

app.use(express.json());

app.get('/', (req, res) => {
  res.json({ message: 'Task Manager API is running' });
});

app.listen(port, () => {
  console.log(`Task Manager API running on http://localhost:${port}`);
});
Test the server

Time to confirm everything works. You'll start the server, then send an HTTP request to your health-check route.

  • Start the server by running this command in your terminal:
node app.js

You should see this output in the terminal:

Task Manager API running on http://localhost:3000

What's happening here?

node app.js tells Node.js to execute your entry point file. The server starts and keeps running, waiting for incoming requests. Your terminal is now occupied by the running server process.

  • Open a second terminal panel. In Cursor, press Cmd+Shift+` (macOS) or Ctrl+Shift+` (Windows) to open a new terminal tab.
  • Send a request to your health-check route by running:
curl http://localhost:3000/

You should see this JSON response:

{"message":"Task Manager API is running"}

Use your browser instead

You can also open http://localhost:3000/ in your browser to see the same JSON response. Browsers send GET requests by default, which is exactly what the health-check route handles.

Not seeing the expected response?

  • If you see Connection refused, make sure node app.js is still running in your first terminal.
  • If you see a syntax error when starting the server, check that the backticks in console.log are template literal backticks (the key above Tab), not regular single quotes.
  • If port 3000 is already in use, stop the other process or change const port = 3000 to another number like 3001.

Help me debug my Express server startup

  • Once you've confirmed the response, stop the server by pressing Ctrl+C in the first terminal.

Your server is live and responding to requests. Next up, you'll build the full set of CRUD routes for managing tasks.

Build the Task CRUD Routes

Your server is running and responding to requests. Now it is time to give it something useful to do.

In this step, you will build five endpoints that let clients create, read, update, and delete tasks. These are the CRUD operations that form the backbone of nearly every REST API.

In this step, get ready to:
  • Create a dedicated route file using Express Router.
  • Implement five CRUD endpoints with proper HTTP status codes.
  • Mount the router in your server and test every endpoint with curl.
Create the task routes file

Express Router lets you group related routes into their own module. Instead of defining every endpoint directly in app.js, you create a router file that handles one resource (tasks) and plug it into the main app.

  • In Cursor, create a new file called tasks.js inside the routes/ folder.
  • Add the module setup and the two GET routes by pasting this code:
const express = require('express');
const router = express.Router();

// In-memory data store
let tasks = [];
let nextId = 1;

// GET / - List all tasks
router.get('/', (req, res) => {
  res.json(tasks);
});

// GET /:id - Get a single task by ID
router.get('/:id', (req, res) => {
  const task = tasks.find(t => t.id === parseInt(req.params.id));
  if (!task) {
    const error = new Error('Task not found');
    error.status = 404;
    throw error;
  }
  res.json(task);
});

What does this code do?

express.Router() creates a mini-app that handles its own routes. You will mount it on a path later so every route defined here becomes relative to that mount point.

The tasks array and nextId counter act as a simple in-memory database. Data resets when the server restarts.

The GET /:id route uses req.params.id to grab the ID from the URL. If no task matches, it throws an error with a 404 status. Express 5 catches thrown errors automatically.

You will not see any output yet. The router is not connected to your server until a later substep.

  • Below the GET routes, add the POST route that creates a new task:
// POST / - Create a new task
router.post('/', (req, res) => {
  const task = {
    id: nextId++,
    title: req.body.title,
    description: req.body.description || '',
    completed: false,
    createdAt: new Date().toISOString()
  };
  tasks.push(task);
  res.status(201).json(task);
});

What does this code do?

The POST handler reads title and description from the JSON request body (req.body). It builds a task object, assigns an auto-incrementing ID, and pushes it into the array.

res.status(201).json(task) sends back the newly created task with HTTP status 201 Created. This tells clients the resource was successfully created.

  • Below the POST route, add the PUT and DELETE routes plus the module export:
// PUT /:id - Update an existing task
router.put('/:id', (req, res) => {
  const task = tasks.find(t => t.id === parseInt(req.params.id));
  if (!task) {
    const error = new Error('Task not found');
    error.status = 404;
    throw error;
  }
  task.title = req.body.title;
  task.description = req.body.description !== undefined ? req.body.description : task.description;
  task.completed = req.body.completed !== undefined ? req.body.completed : task.completed;
  res.json(task);
});

// DELETE /:id - Remove a task
router.delete('/:id', (req, res) => {
  const index = tasks.findIndex(t => t.id === parseInt(req.params.id));
  if (index === -1) {
    const error = new Error('Task not found');
    error.status = 404;
    throw error;
  }
  tasks.splice(index, 1);
  res.status(204).send();
});

module.exports = router;

What does this code do?

The PUT handler finds a task by ID and updates only the fields provided in the request body. If description or completed are not sent, they keep their existing values.

The DELETE handler uses findIndex and splice to remove the task from the array. It responds with status 204 No Content because there is nothing to send back after a deletion.

module.exports = router makes the router available for other files to import.

  • Save routes/tasks.js.

Seeing red squiggles in Cursor?

Check that every opening brace { has a matching closing brace }. Missing a closing brace on a route handler is the most common cause of syntax errors in this file.

Also confirm module.exports = router appears at the very end of the file, outside all route handlers.

help me fix syntax errors in my routes/tasks.js file

✔️ Awesome, I've got everything!

Great. Double check you have saved routes/tasks.js before moving on.

ⓧ I'd like to double check the full code

const express = require('express');
const router = express.Router();

let tasks = [];
let nextId = 1;

router.get('/', (req, res) => {
  res.json(tasks);
});

router.get('/:id', (req, res) => {
  const task = tasks.find(t => t.id === parseInt(req.params.id));
  if (!task) {
    const error = new Error('Task not found');
    error.status = 404;
    throw error;
  }
  res.json(task);
});

router.post('/', (req, res) => {
  const task = {
    id: nextId++,
    title: req.body.title,
    description: req.body.description || '',
    completed: false,
    createdAt: new Date().toISOString()
  };
  tasks.push(task);
  res.status(201).json(task);
});

router.put('/:id', (req, res) => {
  const task = tasks.find(t => t.id === parseInt(req.params.id));
  if (!task) {
    const error = new Error('Task not found');
    error.status = 404;
    throw error;
  }
  task.title = req.body.title;
  task.description = req.body.description !== undefined ? req.body.description : task.description;
  task.completed = req.body.completed !== undefined ? req.body.completed : task.completed;
  res.json(task);
});

router.delete('/:id', (req, res) => {
  const index = tasks.findIndex(t => t.id === parseInt(req.params.id));
  if (index === -1) {
    const error = new Error('Task not found');
    error.status = 404;
    throw error;
  }
  tasks.splice(index, 1);
  res.status(204).send();
});

module.exports = router;
Mount the router in app.js

The router file exists but your server does not know about it yet. You need to import the router and mount it on a URL path so Express knows which requests to forward to it.

  • Switch back to app.js in Cursor.
  • Find this line at the top of the file:
const express = require('express');
  • Add this import directly below it:
const tasksRouter = require('./routes/tasks');
  • Now find the health-check route:
app.get('/', (req, res) => {
  res.json({ message: 'Task Manager API is running' });
});
  • Add this line directly below the closing }); of the health-check route:
app.use('/api/tasks', tasksRouter);

What does this line do?

app.use('/api/tasks', tasksRouter) mounts the router at the /api/tasks path. Every route defined in the router becomes relative to this path. For example, router.get('/:id') now responds to GET /api/tasks/:id.

  • Save app.js.
  • Start the server by running this command in your terminal:
node app.js
  • You should see Task Manager API running on http://localhost:3000 printed in the terminal. This confirms the router loaded without errors.

Seeing an error about the module not found?

Check that tasks.js is inside the routes/ folder, not in the project root. The import path ./routes/tasks must match the actual file location exactly.

Also verify the file is named tasks.js (not task.js without the s).

help me fix a module not found error in my Express app

✔️ Awesome, I've got everything!

Great. Double check you have saved app.js before moving on.

ⓧ I'd like to double check the full code

const express = require('express');
const tasksRouter = require('./routes/tasks');

const app = express();
const port = 3000;

app.use(express.json());

app.get('/', (req, res) => {
  res.json({ message: 'Task Manager API is running' });
});

app.use('/api/tasks', tasksRouter);

app.listen(port, () => {
  console.log(`Task Manager API running on http://localhost:${port}`);
});
Test each endpoint with curl

Your server is running in the current terminal window, so you need a second terminal to send requests. Leave the server running and open a new terminal.

  • Open a new terminal tab or window. In Cursor, press Cmd+Shift+` (macOS) or Ctrl+Shift+` (Windows) to open a second terminal panel.
  • Create a task by running this command:
curl -X POST http://localhost:3000/api/tasks -H "Content-Type: application/json" -d '{"title":"Learn Express","description":"Build a REST API"}'
  • You should see a JSON response with your new task, an id of 1, and completed set to false.
  • List all tasks by running:
curl http://localhost:3000/api/tasks
  • You should see an array containing the task you just created.
  • Get a single task by its ID:
curl http://localhost:3000/api/tasks/1
  • You should see just the one task object with id of 1.
  • Update the task to mark it complete:
curl -X PUT http://localhost:3000/api/tasks/1 -H "Content-Type: application/json" -d '{"title":"Learn Express","completed":true}'
  • You should see the task returned with completed now set to true.
  • Delete the task:
curl -X DELETE http://localhost:3000/api/tasks/1 -v
  • The -v flag shows verbose output. Look for 204 No Content in the response status line. This confirms the task was removed.
  • Verify the task is gone by listing all tasks again:
curl http://localhost:3000/api/tasks
  • You should see an empty array [].

Why these status codes?

RESTful APIs use specific HTTP status codes to communicate what happened. 200 means success. 201 means a new resource was created. 204 means success with no body to return.

Using the right codes helps clients (and other developers) understand your API without reading documentation.

Getting connection refused?

Make sure the server is still running in your first terminal. If you see Task Manager API running on http://localhost:3000 there, the server is alive.

If you accidentally closed it, run node app.js again in the first terminal, then retry your curl command in the second terminal.

help me debug connection refused errors when testing my Express API with curl

  • Stop the server in your first terminal by pressing Ctrl+C.

All five endpoints are working. Next up, you will add custom middleware that logs every request and validates incoming data before it reaches your routes.

Add Custom Middleware

Your CRUD routes are working and every endpoint responds correctly. Now it's time to add logic that runs before your route handlers even see the request.

Middleware functions sit between the incoming request and your route handler. They can log information, validate data, check authentication, or transform the request. In this step, you'll create two custom middleware functions: one for logging every request, and one for validating task data.

In this step, get ready to:
  • Create a request logger middleware that logs every incoming request.
  • Create an input validation middleware that rejects requests with missing titles.
  • Test both middleware functions with curl.
Create the request logger middleware

Every production API needs visibility into what requests it receives. A logger middleware runs on every incoming request and prints useful details to the terminal.

  • Create a new file called logger.js inside your middleware/ folder by running this command:
touch middleware/logger.js
  • Open middleware/logger.js in Cursor and add the following code:
const logger = (req, res, next) => {
  // Build a timestamp and log the request method + URL
  const timestamp = new Date().toISOString();
  console.log(`[${timestamp}] ${req.method} ${req.url}`);
  next();
};

module.exports = logger;

What does this code do?

This function takes three arguments: req (the request), res (the response), and next (a function that passes control to the next middleware in the chain).

It logs the current timestamp, the HTTP method (GET, POST, etc.), and the URL path. Calling next() at the end is essential. Without it, the request would hang and never reach your route handler.

  • Save middleware/logger.js.

Now you need to tell Express to use this logger on every request.

  • In app.js, find this line near the top:
const tasksRouter = require('./routes/tasks');
  • Add this import directly below it:
const logger = require('./middleware/logger');
  • Next, find this line:
app.use(express.json());
  • Add this line directly below it:
app.use(logger);

Why does order matter here?

Middleware registered with app.use() runs in the order you define it. Placing the logger after express.json() but before your routes means every request gets logged regardless of which endpoint it hits.

  • Save app.js.

Not sure where to add the lines?

The import goes on line 3, right after the other require statements. The app.use(logger) line goes directly after app.use(express.json()) and before the app.get('/') health-check route.

help me figure out where to place my logger middleware in app.js

✔️ Awesome, I've got everything!

Great. Double check you've saved both middleware/logger.js and app.js.

ⓧ I'd like to double check the full code

const logger = (req, res, next) => {
  // Build a timestamp and log the request method + URL
  const timestamp = new Date().toISOString();
  console.log(`[${timestamp}] ${req.method} ${req.url}`);
  next();
};

module.exports = logger;
const express = require('express');
const tasksRouter = require('./routes/tasks');
const logger = require('./middleware/logger');

const app = express();
const port = 3000;

app.use(express.json());
app.use(logger);

app.get('/', (req, res) => {
  res.json({ message: 'Task Manager API is running' });
});

app.use('/api/tasks', tasksRouter);

app.listen(port, () => {
  console.log(`Task Manager API running on http://localhost:${port}`);
});
Create the validation middleware

Right now, your POST and PUT routes accept any request body, even one with no title at all. A validation middleware can catch bad input before the route handler processes it.

  • Create a new file called validate.js inside your middleware/ folder by running this command:
touch middleware/validate.js
  • Open middleware/validate.js in Cursor and add the following code:
const validate = (req, res, next) => {
  // Extract the title from the request body
  const { title } = req.body;
  // Reject if title is missing, not a string, or empty whitespace
  if (!title || typeof title !== 'string' || title.trim().length === 0) {
    const error = new Error('Title is required and must be a non-empty string');
    error.status = 400;
    throw error;
  }
  next();
};

module.exports = validate;

What does this code do?

This middleware checks three conditions: the title exists, it's a string, and it's not just whitespace. If any check fails, it creates an error with a 400 status code and throws it.

Express 5 automatically catches errors thrown in route handlers and middleware. It then forwards them to the error-handling middleware (which you'll build in the next step).

  • Save middleware/validate.js.

Unlike the logger, this validation middleware should only run on routes that accept task data (POST and PUT). You'll apply it directly in the router rather than globally.

  • In routes/tasks.js, find this line at the top:
const router = express.Router();
  • Add this import directly below it:
const validate = require('../middleware/validate');
  • Now find the POST route definition:
router.post('/', (req, res) => {
  • Add validate as a second argument between the path and the callback, so the line looks like this:
router.post('/', validate, (req, res) => {
  • Find the PUT route definition:
router.put('/:id', (req, res) => {
  • Add validate the same way:
router.put('/:id', validate, (req, res) => {

Route-level vs global middleware

When you pass middleware as an argument to a route (like router.post('/', validate, handler)), it runs only for that specific route. Global middleware registered with app.use() runs for every request.

This gives you fine-grained control. The logger sees everything, but validation only guards routes that create or update data.

  • Save routes/tasks.js.

Seeing a syntax error?

Make sure validate sits between the path string and the callback function, separated by commas. A common mistake is placing it after the callback or forgetting the comma.

help me fix a syntax error in my Express route after adding validation middleware

✔️ Awesome, I've got everything!

Great. Double check you've saved both middleware/validate.js and routes/tasks.js.

ⓧ I'd like to double check the full code

const validate = (req, res, next) => {
  // Extract the title from the request body
  const { title } = req.body;
  // Reject if title is missing, not a string, or empty whitespace
  if (!title || typeof title !== 'string' || title.trim().length === 0) {
    const error = new Error('Title is required and must be a non-empty string');
    error.status = 400;
    throw error;
  }
  next();
};

module.exports = validate;
const express = require('express');
const router = express.Router();
const validate = require('../middleware/validate');

let tasks = [];
let nextId = 1;

router.get('/', (req, res) => {
  res.json(tasks);
});

router.get('/:id', (req, res) => {
  const task = tasks.find(t => t.id === parseInt(req.params.id));
  if (!task) {
    const error = new Error('Task not found');
    error.status = 404;
    throw error;
  }
  res.json(task);
});

router.post('/', validate, (req, res) => {
  const task = {
    id: nextId++,
    title: req.body.title,
    description: req.body.description || '',
    completed: false,
    createdAt: new Date().toISOString()
  };
  tasks.push(task);
  res.status(201).json(task);
});

router.put('/:id', validate, (req, res) => {
  const task = tasks.find(t => t.id === parseInt(req.params.id));
  if (!task) {
    const error = new Error('Task not found');
    error.status = 404;
    throw error;
  }
  task.title = req.body.title;
  task.description = req.body.description !== undefined ? req.body.description : task.description;
  task.completed = req.body.completed !== undefined ? req.body.completed : task.completed;
  res.json(task);
});

router.delete('/:id', (req, res) => {
  const index = tasks.findIndex(t => t.id === parseInt(req.params.id));
  if (index === -1) {
    const error = new Error('Task not found');
    error.status = 404;
    throw error;
  }
  tasks.splice(index, 1);
  res.status(204).send();
});

module.exports = router;
Test both middleware functions

Time to confirm the logger prints output and the validator rejects bad input.

  • Start the server in your terminal by running:
node app.js
  • Open a second terminal tab or window (press Cmd+T in macOS Terminal or click + in Windows Terminal).
  • Test the validation by sending a POST request with no title:
curl -X POST http://localhost:3000/api/tasks -H "Content-Type: application/json" -d '{}'

You should see a 400 status error response. The response body will be HTML with the error message because Express's default error handler formats errors as HTML. You'll replace this with clean JSON responses in the next step.

  • Now send a valid request to confirm the happy path still works:
curl -X POST http://localhost:3000/api/tasks -H "Content-Type: application/json" -d '{"title":"Buy groceries"}'

You should see a JSON response with your new task (status 201).

  • Switch back to the terminal tab running the server.

You should see timestamped log lines for both requests, similar to:

[2026-06-24T10:30:01.123Z] POST /api/tasks
[2026-06-24T10:30:05.456Z] POST /api/tasks

What should I see?

Every request that hits your server now produces a log line with the timestamp, HTTP method, and URL path. The invalid request (empty body) produces a log line AND returns a 400 error. The valid request produces a log line and returns the created task as JSON.

Not seeing any log output?

Make sure you saved app.js after adding app.use(logger). If you edited the file while the server was running, stop it with Ctrl+C and restart with node app.js.

Also confirm that app.use(logger) appears before app.use('/api/tasks', tasksRouter) in your file. Middleware only applies to routes registered after it.

help me debug why my Express logger middleware isn't printing anything

  • Stop the server by pressing Ctrl+C in the server terminal.

Both middleware functions are working. The logger gives you visibility into every request, and the validator blocks bad data at the door. Next up, you'll build a centralized error handler so those thrown errors return clean JSON responses instead of HTML.

Add Error Handling

Your API now logs every request and validates incoming data before it reaches your route handlers.

But what happens when something goes wrong? Right now, if a route throws an error, Express returns its default HTML error page.

That's not useful for API consumers who expect JSON. In this step, you'll build a centralized error handler that catches every error and returns a consistent JSON response.

In this step, get ready to:
  • Create a centralized error handler middleware.
  • Register it as the last middleware in your app.
  • Test that errors return consistent JSON responses.
Create the error handler middleware

Express identifies error-handling middleware by its function signature. A function with four arguments (err, req, res, next) tells Express this is an error handler, not regular middleware.

  • Create a new file called errorHandler.js inside your middleware folder.
  • Add the following code:
const errorHandler = (err, req, res, next) => {
  // Default to 500 if no status is set on the error
  const status = err.status || 500;
  const message = err.message || 'Internal Server Error';

  // Log the error for debugging
  console.error(`[ERROR] ${status} - ${message}`);

  // Send a consistent JSON error response
  res.status(status).json({
    error: {
      status,
      message
    }
  });
};

module.exports = errorHandler;

What does this code do?

The four-argument signature (err, req, res, next) is what tells Express this function handles errors. All four arguments must be declared, even if you don't use next.

The handler reads err.status (defaulting to 500 for unexpected errors) and err.message. It logs the error to the terminal, then sends a JSON response in a predictable format that any client can parse.

  • Save middleware/errorHandler.js.

You won't see a change yet. The next chunk connects this handler to your app.

Seeing a syntax error when you save?

Make sure you have both the opening and closing curly braces for the function body. The arrow function syntax (err, req, res, next) => { ... } requires braces around the body when it contains multiple statements.

help me fix a syntax error in my errorHandler.js middleware

Register the error handler in app.js

Error-handling middleware must be registered last, after all routes. This way, any error thrown or passed to next() from your routes will fall through to the error handler.

  • In app.js, find this line near the top of the file:
const logger = require('./middleware/logger');
  • Add this line directly below it:
const errorHandler = require('./middleware/errorHandler');
  • Now scroll down and find this line:
app.use('/api/tasks', tasksRouter);
  • Add this line directly below it:
app.use(errorHandler);

Why does placement matter?

Express processes middleware in the order you register it. If you place the error handler before your routes, errors thrown by those routes would never reach it.

By registering app.use(errorHandler) after all routes, you guarantee it's the last stop for any error in the entire request lifecycle.

What about the errors in routes/tasks.js?

Your task routes already throw errors with a status property (like error.status = 404) from the previous step. The error handler you just created reads that property to set the correct HTTP status code.

  • Save app.js.

✔️ Awesome, I've got everything!

Great. Make sure you've saved app.js before moving on to testing.

ⓧ I'd like to double check the full code

Here's the complete app.js with the error handler imported and registered:

const express = require('express');
const tasksRouter = require('./routes/tasks');
const logger = require('./middleware/logger');
const errorHandler = require('./middleware/errorHandler');

const app = express();
const port = 3000;

app.use(express.json());
app.use(logger);

app.get('/', (req, res) => {
  res.json({ message: 'Task Manager API is running' });
});

app.use('/api/tasks', tasksRouter);

app.use(errorHandler);

app.listen(port, () => {
  console.log(`Task Manager API running on http://localhost:${port}`);
});

Not sure where to add the lines?

The import goes on line 4, right after your other require statements. The app.use(errorHandler) line goes between app.use('/api/tasks', tasksRouter) and app.listen(). Use the "double check" tab above to compare your file.

help me figure out where to place the error handler in app.js

Test error scenarios

Time to confirm that your error handler catches errors and formats them as JSON. You'll trigger two different error types to verify everything works.

  • Start your server by running:
node app.js
  • Open a second terminal tab. Press Cmd+Shift+` (macOS) or Ctrl+Shift+` (Windows) in Cursor to open a new terminal.
  • Request a task that doesn't exist by running:
curl http://localhost:3000/api/tasks/999

You should see a JSON response like this:

{"error":{"status":404,"message":"Task not found"}}
  • Now test a validation error by sending a request with an empty title:
curl -X POST http://localhost:3000/api/tasks -H "Content-Type: application/json" -d '{"title":""}'

You should see:

{"error":{"status":400,"message":"Title is required and must be a non-empty string"}}

What's happening here?

Both errors follow the same JSON format. The 404 was thrown by the GET /:id route when it couldn't find a task. The 400 was thrown by the validate middleware when it detected an empty title.

Express 5 automatically catches thrown errors in route handlers and middleware, then passes them to your error handler. No manual try/catch wrappers needed.

  • Check the terminal tab where your server is running. You should see the logger output for both requests, plus [ERROR] lines from the error handler.

Getting an HTML error page instead of JSON?

This means the error handler is not registered or is registered in the wrong place. Make sure app.use(errorHandler) appears after app.use('/api/tasks', tasksRouter) in app.js.

Also confirm the import at the top uses the exact path ./middleware/errorHandler and that the file is named errorHandler.js (case-sensitive).

help me debug why my error handler isn't returning JSON

  • Stop the server by pressing Ctrl+C in the terminal where it's running.

Secret mission

Add Filtering, Search, and Pagination

Your GET /api/tasks endpoint returns every task in the array every time. Real APIs let users filter, search, and paginate results so they only receive the data they need. Upgrade your endpoint to handle all three.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine with no cloud services or external APIs. There are no ongoing costs to worry about.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The task-manager-api project folder on your Desktop (contains all project files and node_modules).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep the project as a reference.

  • Your task-manager-api folder stays on your Desktop.
  • The server only runs when you manually start it with node app.js. It does not run in the background.

Pause - I'll come back to this later

Nothing is running unless you manually started the server. If the server is running, stop it.

  • Switch to the terminal where the server is running.
  • Press Ctrl+C to stop the server.
  • Your project files remain on your Desktop for next time.

Delete - I don't want to use this again

Remove all project files and start fresh. This deletes the entire project folder including node_modules.

  • If the server is running, press Ctrl+C in the terminal to stop it.
  • Navigate to your Desktop and remove the project folder by running these commands:

macOS

cd ~/Desktop
rm -rf task-manager-api

Windows

cd ~\Desktop
rmdir /s /q task-manager-api
  • Confirm the folder is gone by running ls (macOS) or dir (Windows) on your Desktop. You should no longer see task-manager-api in the output.

Nice Work!

Nice Work!

You just built a fully functional REST API from scratch using Express.js. This is the same architecture pattern that powers production Node.js backends at companies of every size.

Here's what you learned:

  • Built a REST API with 5 CRUD endpoints following RESTful conventions for proper HTTP status codes and JSON responses.
  • Structured the project with modular routers and middleware, the same pattern used in production Express apps.
  • Implemented centralized error handling that catches errors across the entire application and returns consistent JSON error responses.
  • Secret Mission: Added filtering, search, and pagination to handle real-world data querying patterns.

Ready to quiz yourself?