Free tools Windows power users keep installed
One-click scans. No signup required.
This guide builds a small task-management REST API with Node.js and Express 5. It provides five task endpoints, input checks, a fixed JSON error shape, and a 404 path for unknown routes. Tasks are held in memory, so they are lost when the process restarts. The routing and error-handling layers stay the same when you later move storage to a database, as described below.
Assumptions this tutorial makes
The title does not fix these choices, so the tutorial states them up front. Each one can be changed, and the notes say what changes.
As an Amazon Associate I earn from qualifying purchases.
- Express major version: Express 5 (the 5.x line). Express 5 forwards errors from promise-returning route handlers automatically. Section ‘Express 4 differences’ covers the changes needed for Express 4.
- Module system: CommonJS (
require), which is whatnpm init -yproduces by default. An ESM alternative is noted in the setup steps. - Persistence: an in-memory
Mapbehind a small store module. It is a learning simplification, not durable storage. - Authentication: out of scope. Every endpoint is open, so the API is for local learning and should not be exposed publicly as written.
- Pagination: out of scope.
GET /tasksreturns the full list.
Resource model and API contract
The API exposes one resource, a task, through a collection URL and an item URL. The field rules below are design decisions for this tutorial, not requirements imposed by Express.
Recommended Free Tools
Task fields
| Field | Set by | Type | Rules |
|---|---|---|---|
| id | Server | String (UUID) | Generated on create. Clients cannot send it; an id key in a request body returns 400. |
| title | Client | String | Required on create. Trimmed; must be non-empty and at most 200 characters. |
| completed | Client | Boolean | Optional on create (defaults to false). Must be true or false. |
| createdAt | Server | ISO 8601 string | Set once, on create. |
| updatedAt | Server | ISO 8601 string | Set on create and on every successful update. |
This tutorial rejects unknown fields with a 400 instead of silently dropping them. That makes mistakes visible to the client, at the cost of stricter clients.
#1 Best Overall
Endpoints and status codes
| Method and path | Success response | Purpose |
|---|---|---|
| GET /tasks | 200 OK with { "data": [...] } |
List all tasks |
| POST /tasks | 201 Created, with a Location header |
Create a task |
| GET /tasks/:id | 200 OK | Read one task |
| PATCH /tasks/:id | 200 OK | Change one or more of title and completed |
| DELETE /tasks/:id | 204 No Content | Delete one task |
Express routes requests by HTTP method and path, and a router can be mounted under a prefix, which is how /tasks is organised here. See the official Express routing guide. The status codes in the table are this tutorial’s contract; Express does not prescribe them.
Set up the project
- Confirm your Node.js version with
node --version. Express 5 requires Node.js 18 or later. - Create the project and initialise npm:
mkdir task-api cd task-api npm init -y - Install Express 5:
npm install express@5 - Create the files used in the next section:
mkdir routes store touch app.js errors.js routes/tasks.js store/taskStore.js - Start the server with
node app.js. It listens on port 3000 unless thePORTenvironment variable is set.
To use ESM instead, add "type": "module" to package.json, switch every require and module.exports to import and export, and include the .js extension in relative imports. The Express middleware guide shows both module forms.
The application code
The code is split into four files: the entry point, the error helpers, the routes, and the store. Keeping the store separate means the routes never touch storage details.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
Rank #2
app.js
const express = require('express');
const tasksRouter = require('./routes/tasks');
const { notFound, errorHandler } = require('./errors');
const app = express();
app.use(express.json({ limit: '100kb' }));
app.use('/tasks', tasksRouter);
app.use(notFound);
app.use(errorHandler);
if (require.main === module) {
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log('Task API listening on port ' + port);
});
}
module.exports = app;
The JSON parser is registered before the routes, so every route sees a parsed req.body. The 100 KB limit is written out explicitly, although it matches the parser default. The error handlers come last, after all routes.
errors.js
class HttpError extends Error {
constructor(status, code, message) {
super(message);
this.status = status;
this.code = code;
}
}
function notFound(req, res) {
res.status(404).json({
error: { code: 'route_not_found', message: 'No route for ' + req.method + ' ' + req.path + '.' }
});
}
function errorHandler(err, req, res, next) {
if (res.headersSent) {
return next(err);
}
if (err.type === 'entity.parse.failed') {
return res.status(400).json({
error: { code: 'invalid_json', message: 'Request body is not valid JSON.' }
});
}
if (err instanceof HttpError) {
return res.status(err.status).json({ error: { code: err.code, message: err.message } });
}
const status = err.status || err.statusCode || 500;
if (status >= 500) {
console.error(err);
return res.status(500).json({
error: { code: 'internal_error', message: 'Something went wrong.' }
});
}
return res.status(status).json({ error: { code: 'request_error', message: err.message } });
}
module.exports = { HttpError, notFound, errorHandler };
routes/tasks.js
const express = require('express');
const store = require('../store/taskStore');
const { HttpError } = require('../errors');
const router = express.Router();
const CREATE_FIELDS = ['title', 'completed'];
const UPDATE_FIELDS = ['title', 'completed'];
function badRequest(message) {
return new HttpError(400, 'validation_error', message);
}
function assertAllowedFields(body, allowed) {
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
throw badRequest('Request body must be a JSON object.');
}
const unknown = Object.keys(body).filter((key) => !allowed.includes(key));
if (unknown.length > 0) {
throw badRequest('Unknown field(s): ' + unknown.join(', ') + '.');
}
return body;
}
function checkTitle(value) {
if (typeof value !== 'string' || value.trim() === '') {
throw badRequest('title must be a non-empty string.');
}
if (value.trim().length > 200) {
throw badRequest('title must be 200 characters or fewer.');
}
return value.trim();
}
function checkCompleted(value) {
if (typeof value !== 'boolean') {
throw badRequest('completed must be true or false.');
}
return value;
}
function loadTask(req) {
const task = store.find(req.params.id);
if (!task) {
throw new HttpError(404, 'not_found', 'Task not found.');
}
return task;
}
router.get('/', (req, res) => {
res.json({ data: store.list() });
});
router.post('/', (req, res) => {
const body = assertAllowedFields(req.body, CREATE_FIELDS);
if (body.title === undefined) {
throw badRequest('title is required.');
}
const fields = { title: checkTitle(body.title) };
if (body.completed !== undefined) {
fields.completed = checkCompleted(body.completed);
}
const task = store.create(fields);
res.status(201).location('/tasks/' + task.id).json({ data: task });
});
router.get('/:id', (req, res) => {
res.json({ data: loadTask(req) });
});
router.patch('/:id', (req, res) => {
loadTask(req);
const body = assertAllowedFields(req.body, UPDATE_FIELDS);
const changes = {};
if (body.title !== undefined) changes.title = checkTitle(body.title);
if (body.completed !== undefined) changes.completed = checkCompleted(body.completed);
if (Object.keys(changes).length === 0) {
throw badRequest('Send at least one of: title, completed.');
}
res.json({ data: store.update(req.params.id, changes) });
});
router.delete('/:id', (req, res) => {
loadTask(req);
store.remove(req.params.id);
res.status(204).end();
});
module.exports = router;
The PATCH handler checks that the task exists before it validates the body, so an unknown ID returns 404 even when the body is also wrong. This ordering is a choice; reversing it is equally valid if you document it.
store/taskStore.js
const { randomUUID } = require('node:crypto');
const tasks = new Map();
function list() {
return Array.from(tasks.values());
}
function find(id) {
return tasks.get(id) || null;
}
function create(fields) {
const now = new Date().toISOString();
const task = {
id: randomUUID(),
title: fields.title,
completed: fields.completed === true,
createdAt: now,
updatedAt: now,
};
tasks.set(task.id, task);
return task;
}
function update(id, changes) {
const current = tasks.get(id);
if (!current) return null;
const next = { ...current, ...changes, updatedAt: new Date().toISOString() };
tasks.set(id, next);
return next;
}
function remove(id) {
return tasks.delete(id);
}
module.exports = { list, find, create, update, remove };
How errors flow through the app
Every failure ends up in one of three places, and each produces the same JSON shape: { "error": { "code": ..., "message": ... } }.
Rank #3
- Validation and missing records throw an
HttpErrorfrom the route. Express passes synchronous throws to the error pipeline in both Express 4 and Express 5, so no extra wrapper is needed here. - Malformed JSON is raised by
express.json()before the routes run. The handler recognises the parser’sentity.parse.failedtype and returns 400. - Oversized bodies are rejected by the same parser with a 4xx status (413 for bodies above the limit). The generic branch returns that status and the parser’s message.
- Unexpected failures are logged on the server, and the client receives only a generic 500 message, so stack traces and internal details are never sent to callers.
The handler calls next(err) when headers have already been sent. The Express 5 error handling guide describes this delegation: once a response has started, the default handler must close the connection, and a custom handler should not try to write a second response.
Do these 3 things before closing this tab:
1Fix the driver behind crashes, sound loss and screen glitches2Repair Windows errors before they cause bigger problems3Scan for outdated or missing drivers - takes under a minuteTest the CRUD flow
With the server running on port 3000, the following commands exercise every endpoint. Replace <id> with the id returned by the POST request.
curl -i -X POST http://localhost:3000/tasks -H 'Content-Type: application/json' -d '{"title": "Write the API guide"}'
curl -i http://localhost:3000/tasks
curl -i http://localhost:3000/tasks/<id>
curl -i -X PATCH http://localhost:3000/tasks/<id> -H 'Content-Type: application/json' -d '{"completed": true}'
curl -i -X DELETE http://localhost:3000/tasks/<id>
The table lists the response each failure case should produce when you run it against this code.
Rank #4
| Request | Expected status | Error code |
|---|---|---|
POST /tasks with {"title": "Buy milk"} |
201 Created | None; Location header is set |
POST /tasks with {} |
400 | validation_error (title is required) |
| POST /tasks with a body that is not JSON | 400 | invalid_json |
POST /tasks with {"title": "x", "id": "abc"} |
400 | validation_error (unknown field) |
| GET /tasks/does-not-exist | 404 | not_found |
PATCH /tasks/<id> with {"completed": "yes"} |
400 | validation_error (completed must be a boolean) |
| DELETE /tasks/<id> a second time | 404 | not_found |
| GET /notes | 404 | route_not_found |
Route parameters and query parameters
/tasks/:id uses a route parameter, read as req.params.id. Query strings such as ?completed=true are read from req.query and are not part of the routes above. Filtering and pagination would be added there, for example limit and cursor parameters on GET /tasks, but they change the response contract and need their own rules.
Express 4 differences
If you must stay on Express 4, install it with npm install express@4. The routes above still work, because they are synchronous. The differences appear once a handler awaits asynchronous work, such as a database call.
| Behaviour | Express 5 | Express 4 |
|---|---|---|
| Rejected promise from an async route handler | Forwarded to next(err) automatically |
Not forwarded; the request can hang unless you catch the error and call next(err) |
| Synchronous throw in a route handler | Forwarded to error middleware | Forwarded to error middleware |
req.body when the request has no body |
Left undefined by express.json() |
Set to an empty object {} by the body parser |
On Express 4, wrap async handlers so that rejections reach the error middleware:
function asyncHandler(fn) {
return function (req, res, next) {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
router.get('/:id', asyncHandler(async (req, res) => {
res.json({ data: await store.findAsync(req.params.id) });
}));
Keep the error middleware with four arguments in both versions; Express identifies it by its signature.
Moving beyond the in-memory store
The in-memory store has three limits that matter before any real use:
- All data is lost when the process restarts.
- Each Node.js process has its own
Map. Running several instances, or using cluster mode, gives each one a different set of tasks. - The functions are synchronous, which is why the routes can throw directly.
To add persistence, pick a database and keep its code in a repository module with the same function names. Make those functions return promises, and update the routes to await them. At that point the Express 5 automatic forwarding or the Express 4 wrapper above becomes necessary. The routes, the error handlers, and the tests of HTTP behaviour do not need to change. The choice of database and schema is left to you; this tutorial does not recommend one.
The Tool Desk
Outbyte PC Repair FREEClear out junk files and repair common Windows errorsFree Scan →Outbyte Driver Updater FREEFix the driver behind crashes, sound loss and screen glitchesFind Drivers →Production boundaries
Treat the code above as a learning project until the following are in place:
- Set
NODE_ENV=productionon servers. Express’s default error output includes stack traces outside production. The handler in this tutorial never sends stacks, but other middleware may behave differently depending on this setting. - Use a maintained Express release. Check the current 5.x version on npm before deploying, and watch the official Express site for security advisories. The Express production security guide is a translated page; use the English version on the Express site for the current wording and any newer advisories.
- Protect traffic with TLS. Terminate HTTPS at a reverse proxy or with Node’s
httpsmodule. Do not send task data over plain HTTP on untrusted networks. - Review the body limit and logging. The 100 KB limit and the server-side
console.errorcall are starting points; replace them with values and a logger that suit your traffic.
For background on testing, HTTP and asynchronous code, the Node.js learning hub is a free starting point.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.




