Project: Task Manager API & Front End
Time to put the whole course together. In this project you will build a small but complete task manager: a REST API with Express 5 that stores tasks in a JSON file, validates every request, answers errors in JSON, serves a browser front end, and has automated tests. Students can use it to track assignments; a family can use it for shopping and bills.
You will use ideas from many earlier lessons: modules, fs/promises, environment variables, Express routing and middleware, validation with Zod, error handling, fetch() in the browser and node:test. Build it step by step, run it after each step, and you will have a project you can show in an interview.
What you will build
GET /api/taskslists tasks, with?status=open|done|alland?q=search.POST /api/taskscreates a task with a title, a priority (low,medium,high) and an optional due date.PATCH /api/tasks/:idchanges fields, including marking a task done.DELETE /api/tasks/:iddeletes a task.- A web page to add, tick, filter and delete tasks, served by the same app.
- Tests that check the API automatically.
task-manager/
package.json
server.js starts the server
src/
store.js reads and writes data/tasks.json
schemas.js Zod validation rules
app.js the Express app (routes, errors)
public/
index.html the page
app.js browser code
test/
api.test.js
data/ created automaticallyStep 1: set up the project
mkdir task-manager
cd task-manager
npm init -y
npm pkg set type=module
npm install express zod helmetThen edit package.json so it has these scripts and an engines field:
{
"name": "task-manager",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node --env-file-if-exists=.env server.js",
"dev": "node --watch --env-file-if-exists=.env server.js",
"test": "node --test"
},
"engines": {
"node": ">=24"
}
}--env-file-if-exists loads a .env file when there is one, so you can set PORT or DATA_FILE without changing code. Add node_modules/, .env and data/ to .gitignore.
Step 2: the data store
The store hides how tasks are saved. The rest of the app only calls list, get, create, update and remove. Later you can replace this file with a MySQL or MongoDB version without touching the routes.
// src/store.js
import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
export function createStore(file) {
let tasks = null; // loaded on first use
let saving = Promise.resolve(); // writes run one after another
async function load() {
if (tasks) return tasks;
try {
tasks = JSON.parse(await readFile(file, "utf8"));
} catch (err) {
if (err.code !== "ENOENT") throw err;
tasks = []; // first run: no file yet
}
return tasks;
}
function save() {
saving = saving.then(async () => {
await mkdir(path.dirname(file), { recursive: true });
const temp = `${file}.tmp`;
await writeFile(temp, JSON.stringify(tasks, null, 2));
await rename(temp, file); // replace the old file in one step
});
return saving;
}
return {
async list({ status, q } = {}) {
let result = await load();
if (status === "open") result = result.filter((t) => !t.done);
if (status === "done") result = result.filter((t) => t.done);
if (q) result = result.filter((t) => t.title.toLowerCase().includes(q.toLowerCase()));
return result;
},
async get(id) {
return (await load()).find((t) => t.id === id);
},
async create({ title, priority, dueDate }) {
await load();
const now = new Date().toISOString();
const task = { id: randomUUID(), title, priority, dueDate: dueDate ?? null, done: false, createdAt: now, updatedAt: now };
tasks.push(task);
await save();
return task;
},
async update(id, changes) {
const task = (await load()).find((t) => t.id === id);
if (!task) return undefined;
Object.assign(task, changes, { updatedAt: new Date().toISOString() });
await save();
return task;
},
async remove(id) {
await load();
const index = tasks.findIndex((t) => t.id === id);
if (index === -1) return false;
tasks.splice(index, 1);
await save();
return true;
},
};
}Three design decisions are worth understanding:
- The file is read once and the tasks are kept in memory, so reading is fast. Every change is written back to disk.
- Writes are queued. If two requests save at almost the same moment, two
writeFile()calls could overlap and leave a broken file. Chaining each save onto the previous promise (saving = saving.then(...)) makes them run one after another. - Writes are atomic. The data is first written to
tasks.json.tmpand then renamed over the real file. A rename is a single step, so a crash halfway through writing can never leave a half-writtentasks.json.
createStore(file) is a factory: it takes the file path as an argument instead of hard-coding it. That small choice lets the tests use a temporary file, as you will see in step 7. IDs are random UUIDs from node:crypto, which cannot be guessed or counted like 1, 2, 3.
Step 3: validation rules
// src/schemas.js
import { z } from "zod";
const Title = z.string().trim().min(1, "Title is required").max(120);
const Priority = z.enum(["low", "medium", "high"]);
const DueDate = z.iso.date("Use the format YYYY-MM-DD").nullable();
export const NewTask = z.object({
title: Title,
priority: Priority.default("medium"),
dueDate: DueDate.optional(),
});
export const TaskChanges = z
.object({
title: Title.optional(),
priority: Priority.optional(),
dueDate: DueDate.optional(),
done: z.boolean().optional(),
})
.refine((obj) => Object.keys(obj).length > 0, "Send at least one field to change");
export const ListQuery = z.object({
status: z.enum(["all", "open", "done"]).default("all"),
q: z.string().trim().max(50).optional(),
});Shared pieces such as Title keep the create and update rules identical. .nullable() lets a client clear a due date by sending null. The refine() check rejects an empty PATCH body. Because Zod strips unknown fields, a client cannot set id, createdAt or anything else you did not list.
Step 4: the Express app
// src/app.js
import express from "express";
import helmet from "helmet";
import path from "node:path";
import { z } from "zod";
import { NewTask, TaskChanges, ListQuery } from "./schemas.js";
class HttpError extends Error {
constructor(status, message, details) {
super(message);
this.status = status;
this.details = details;
}
}
function parse(schema, data) {
const result = schema.safeParse(data ?? {});
if (!result.success) {
throw new HttpError(400, "Validation failed", z.flattenError(result.error));
}
return result.data;
}
export function createApp(store) {
const app = express();
app.use(helmet());
app.use(express.json({ limit: "10kb" }));
app.use(express.static(path.join(import.meta.dirname, "..", "public")));
app.get("/api/health", (req, res) => res.json({ status: "ok" }));
app.get("/api/tasks", async (req, res) => {
const { status, q } = parse(ListQuery, req.query);
res.json(await store.list({ status, q }));
});
app.get("/api/tasks/:id", async (req, res) => {
const task = await store.get(req.params.id);
if (!task) throw new HttpError(404, "Task not found");
res.json(task);
});
app.post("/api/tasks", async (req, res) => {
const data = parse(NewTask, req.body);
const task = await store.create(data);
res.status(201).location(`/api/tasks/${task.id}`).json(task);
});
app.patch("/api/tasks/:id", async (req, res) => {
const changes = parse(TaskChanges, req.body);
const task = await store.update(req.params.id, changes);
if (!task) throw new HttpError(404, "Task not found");
res.json(task);
});
app.delete("/api/tasks/:id", async (req, res) => {
if (!(await store.remove(req.params.id))) throw new HttpError(404, "Task not found");
res.sendStatus(204);
});
app.use("/api", (req, res) => {
throw new HttpError(404, `No route for ${req.method} ${req.originalUrl}`);
});
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
if (err.type === "entity.parse.failed") err = new HttpError(400, "Body must be valid JSON");
const status = err.status ?? 500;
if (status >= 500) console.error(err);
res.status(status).json({
error: status >= 500 ? "Something went wrong" : err.message,
...(err.details && { details: err.details }),
});
});
return app;
}Notice how short the route handlers are. Each one validates with parse(), calls the store and sends a response. Every problem is a thrown HttpError, and Express 5 forwards thrown errors from async handlers to the single error handler at the bottom. That handler turns them into JSON, hides details of unexpected 500 errors from users, and logs them for you. The /api catch-all gives unknown API routes a JSON 404, while express.static serves the front end from public. helmet() adds security headers, including a Content-Security-Policy that only allows scripts from your own site, which is why the front-end code lives in a separate file rather than inside the HTML.
createApp(store) receives the store instead of creating it. This is called dependency injection, and it makes the app easy to test.
Step 5: start the server
// server.js
import path from "node:path";
import { createApp } from "./src/app.js";
import { createStore } from "./src/store.js";
const PORT = Number(process.env.PORT ?? 3000);
const DATA_FILE = process.env.DATA_FILE ?? path.join(import.meta.dirname, "data", "tasks.json");
const app = createApp(createStore(DATA_FILE));
const server = app.listen(PORT, (error) => {
if (error) throw error;
console.log(`Task Manager on http://localhost:${PORT}`);
});
function shutdown(signal) {
console.log(`${signal} received, shutting down`);
server.close(() => process.exit(0));
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);npm run devTask Manager on http://localhost:3000
Step 6: try the API
With the server running, call the API from a second terminal with a small fetch() script, curl or an API client. Creating a task:
const res = await fetch("http://localhost:3000/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Submit DBMS assignment", priority: "high", dueDate: "2026-11-05" }),
});
console.log(res.status, await res.json());Here are the responses to some of the requests from a short test script (create, an invalid create, an empty update, delete, then read the deleted task):
POST /api/tasks -> 201
{
"id": "5e14e045-ab7f-4e3f-bd01-e49764521c3a",
"title": "Submit DBMS assignment",
"priority": "high",
"dueDate": "2026-11-05",
"done": false,
"createdAt": "2026-10-30T10:59:35.041Z",
"updatedAt": "2026-10-30T10:59:35.041Z"
}
POST /api/tasks -> 400
{
"error": "Validation failed",
"details": {
"formErrors": [],
"fieldErrors": {
"title": [
"Title is required"
],
"priority": [
"Invalid option: expected one of \"low\"|\"medium\"|\"high\""
],
"dueDate": [
"Use the format YYYY-MM-DD"
]
}
}
}
PATCH /api/tasks/5e14e045-ab7f-4e3f-bd01-e49764521c3a -> 400
{
"error": "Validation failed",
"details": {
"formErrors": [
"Send at least one field to change"
],
"fieldErrors": {}
}
}
DELETE /api/tasks/5e14e045-ab7f-4e3f-bd01-e49764521c3a -> 204
GET /api/tasks/5e14e045-ab7f-4e3f-bd01-e49764521c3a -> 404
{
"error": "Task not found"
}The failed POST sent an empty title, the priority "urgent" and the date "5 Nov", and got every problem back at once. The empty PATCH was rejected by the refine() rule. Open data/tasks.json in your editor after a few requests to see the saved data. Restart the server and the tasks are still there.
Step 7: the front end
The page is plain HTML with a form, three filter buttons and an empty list that JavaScript fills in:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Task Manager</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
form { display: flex; gap: 0.5rem; flex-wrap: wrap; }
input[name="title"] { flex: 1 1 14rem; }
ul { list-style: none; padding: 0; }
li { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0; border-bottom: 1px solid #ddd; }
li.done span { text-decoration: line-through; color: #777; }
.high { color: #b00020; font-weight: bold; }
#error { color: #b00020; min-height: 1.5em; }
nav button[aria-pressed="true"] { font-weight: bold; }
</style>
</head>
<body>
<h1>Task Manager</h1>
<form id="task-form">
<input name="title" placeholder="What needs doing?" required maxlength="120">
<select name="priority">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
</select>
<input name="dueDate" type="date">
<button>Add task</button>
</form>
<p id="error" role="alert"></p>
<nav>
<button data-status="all" aria-pressed="true">All</button>
<button data-status="open" aria-pressed="false">Open</button>
<button data-status="done" aria-pressed="false">Done</button>
</nav>
<ul id="task-list"></ul>
<p id="summary"></p>
<script type="module" src="app.js"></script>
</body>
</html>The browser code calls the API with fetch() and redraws the list after every change:
// public/app.js
const list = document.querySelector("#task-list");
const form = document.querySelector("#task-form");
const errorBox = document.querySelector("#error");
const summary = document.querySelector("#summary");
let currentStatus = "all";
async function api(path, options = {}) {
const res = await fetch(`/api/tasks${path}`, {
...options,
headers: { "Content-Type": "application/json" },
});
if (res.status === 204) return null;
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data;
}
function renderTask(task) {
const li = document.createElement("li");
li.classList.toggle("done", task.done);
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = task.done;
checkbox.addEventListener("change", () => updateTask(task.id, { done: checkbox.checked }));
const title = document.createElement("span");
title.textContent = task.title; // textContent, never innerHTML, for user text
title.className = task.priority === "high" ? "high" : "";
const due = document.createElement("small");
due.textContent = task.dueDate ? `due ${task.dueDate}` : "";
const remove = document.createElement("button");
remove.textContent = "Delete";
remove.addEventListener("click", () => deleteTask(task.id));
li.append(checkbox, title, due, remove);
return li;
}
async function loadTasks() {
try {
const tasks = await api(`?status=${currentStatus}`);
list.replaceChildren(...tasks.map(renderTask));
const open = tasks.filter((t) => !t.done).length;
summary.textContent = `${tasks.length} task(s) shown, ${open} open`;
errorBox.textContent = "";
} catch (err) {
errorBox.textContent = err.message;
}
}
async function updateTask(id, changes) {
try {
await api(`/${id}`, { method: "PATCH", body: JSON.stringify(changes) });
} catch (err) {
errorBox.textContent = err.message;
}
loadTasks();
}
async function deleteTask(id) {
try {
await api(`/${id}`, { method: "DELETE" });
} catch (err) {
errorBox.textContent = err.message;
}
loadTasks();
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(form));
if (!data.dueDate) delete data.dueDate;
try {
await api("", { method: "POST", body: JSON.stringify(data) });
form.reset();
loadTasks();
} catch (err) {
errorBox.textContent = err.message;
}
});
for (const button of document.querySelectorAll("nav button")) {
button.addEventListener("click", () => {
currentStatus = button.dataset.status;
for (const b of document.querySelectorAll("nav button")) {
b.setAttribute("aria-pressed", String(b === button));
}
loadTasks();
});
}
loadTasks();Open http://localhost:3000, add a few tasks, tick one and try the filters. The page builds every element with createElement() and textContent, so a task titled Revise <b>Node.js</b> streams is shown exactly as typed instead of being run as HTML. The browser and the API share the same origin, so no CORS setup is needed. The api() helper turns any error response into a thrown Error carrying the server's message, which the page shows in the alert area.
Step 8: automated tests
The test starts the app on a random port with a store that writes to a temporary folder, so it never touches your real data/tasks.json:
// test/api.test.js
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createApp } from "../src/app.js";
import { createStore } from "../src/store.js";
describe("tasks API", () => {
let server, base, dir;
before(async () => {
dir = await mkdtemp(path.join(tmpdir(), "tasks-"));
const app = createApp(createStore(path.join(dir, "tasks.json")));
server = app.listen(0);
await new Promise((resolve) => server.once("listening", resolve));
base = `http://localhost:${server.address().port}/api/tasks`;
});
after(async () => {
server.close();
await rm(dir, { recursive: true, force: true });
});
const send = (method, url, body) =>
fetch(url, { method, headers: { "Content-Type": "application/json" }, body: body && JSON.stringify(body) });
it("creates, lists, updates and deletes a task", async () => {
let res = await send("POST", base, { title: "Pay electricity bill", priority: "high" });
assert.equal(res.status, 201);
const task = await res.json();
assert.equal(task.done, false);
res = await send("PATCH", `${base}/${task.id}`, { done: true });
assert.equal((await res.json()).done, true);
res = await send("GET", `${base}?status=done`);
assert.equal((await res.json()).length, 1);
res = await send("DELETE", `${base}/${task.id}`);
assert.equal(res.status, 204);
});
it("rejects an empty title", async () => {
const res = await send("POST", base, { title: " " });
assert.equal(res.status, 400);
const body = await res.json();
assert.deepEqual(body.details.fieldErrors.title, ["Title is required"]);
});
it("returns 404 for an unknown task", async () => {
const res = await send("PATCH", `${base}/no-such-id`, { done: true });
assert.equal(res.status, 404);
});
});npm test▶ tasks API ✔ creates, lists, updates and deletes a task (410.2391ms) ✔ rejects an empty title (19.8135ms) ✔ returns 404 for an unknown task (10.0963ms) ✔ tasks API (526.682ms) ℹ tests 3 ℹ suites 1 ℹ pass 3 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 2925.8242
mkdtemp() creates a fresh, uniquely named folder for each run, and after() deletes it. This is exactly why the store and the app accept their dependencies as arguments.
Where to go next
The project is small on purpose, so you can extend it with the rest of the course:
- A real database. Write
src/store-mysql.jswith the same five functions usingmysql2(see Connecting to MySQL), and pass it tocreateApp()inserver.js. The routes and tests stay the same. - User accounts. Add sessions or JWT (see JWT Authentication) and store a
userIdon each task, so every user sees only their own tasks. - Logging and limits. Add
pino-httpandexpress-rate-limit. - Deployment. Run it with PM2 behind Nginx, or write a Dockerfile, as in Deployment.
- TypeScript. Rename the files to
.ts, add types, and run them directly with Node.js 26.
Key points
- Keep layers separate: storage (
store.js), rules (schemas.js), HTTP (app.js) and start-up (server.js). - Factories with dependency injection (
createStore(file),createApp(store)) make code easy to test and to change. - Queue and atomically rename file writes so concurrent requests cannot corrupt the data file.
- Validate every input with schemas, throw
HttpErrors, and convert them to JSON in one error handler. - Build the front end with
fetch(),createElement()andtextContent, and serve it frompublic. - Test the API on a random port with a temporary data file.
Exercise
Add a due-date feature to the API: a new route GET /api/tasks/overdue that returns open tasks whose dueDate is earlier than today, sorted by due date (oldest first). Make sure the route is registered before /api/tasks/:id, and add a test for it.
Show solution
// in src/app.js, before app.get("/api/tasks/:id", ...)
app.get("/api/tasks/overdue", async (req, res) => {
const today = new Date().toISOString().slice(0, 10); // "YYYY-MM-DD"
const tasks = await store.list({ status: "open" });
const overdue = tasks
.filter((t) => t.dueDate && t.dueDate < today)
.toSorted((a, b) => a.dueDate.localeCompare(b.dueDate));
res.json(overdue);
});// in test/api.test.js, inside describe()
it("lists overdue open tasks", async () => {
await send("POST", base, { title: "Renew bus pass", dueDate: "2026-01-15" });
await send("POST", base, { title: "Pay hostel fee", dueDate: "2025-12-01" });
await send("POST", base, { title: "Plan trip to Kochi", dueDate: "2099-05-01" });
const res = await send("GET", `${base}/overdue`);
const titles = (await res.json()).map((t) => t.title);
assert.deepEqual(titles, ["Pay hostel fee", "Renew bus pass"]);
});▶ tasks API ✔ creates, lists, updates and deletes a task (145.2012ms) ✔ rejects an empty title (9.6067ms) ✔ returns 404 for an unknown task (4.4072ms) ✔ lists overdue open tasks (50.9757ms) ✔ tasks API (241.4036ms)
Dates in YYYY-MM-DD form sort correctly as plain strings, so < and localeCompare() work without converting them to Date objects. The route must come before /api/tasks/:id, otherwise Express would treat overdue as a task ID and answer 404.
Quick Quiz
1. Why does the store write to tasks.json.tmp and then rename it?
Writing to a temporary file and renaming it is a standard way to make file updates atomic.
2. What is the benefit of createApp(store) receiving the store as an argument?
This is dependency injection: the app depends on an interface, not a fixed file or database.
3. Why does the front end use textContent to show task titles?
Using textContent for user data prevents cross-site scripting.
4. Why must GET /api/tasks/overdue be registered before GET /api/tasks/:id?
Express runs the first matching route, so fixed paths go before parameter routes.
Learn Node.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
