OnlineCBT Logo OnlineCBT
Node Tutorial
NODE · LESSON 13

Node.js Project: RESTful API Backend Service

By Ratan Updated: 16 Oct 2026

Build a complete production-grade REST API backend service with Express, authentication, and database CRUD.

Level: Beginner to Advanced
Duration: ~60 Mins Deep-Dive

Project: build and verify a task-management REST API

Build a local service for tracking study tasks. A learner can create a task, list tasks, read one, change its completion state and delete it. The project brings together Express routing, validation, middleware, status codes and error handling. Its storage is an in-memory Map: restarting the process intentionally removes all tasks. It is a complete local CRUD exercise, and the final section explains the additional work needed for a public service.

1. Define the contract

  • GET /health returns 200 with a health status.
  • GET /api/tasks returns 200 with a data array, ordered by creation. This exercise stores at most 100 tasks.
  • POST /api/tasks accepts only title, creates an incomplete task, returns 201 and a Location header.
  • GET /api/tasks/:id returns a task or 404.
  • PATCH /api/tasks/:id accepts title, done, or both. Empty updates and unknown fields return 400.
  • DELETE /api/tasks/:id returns 204 with no .tech-lesson-content, or 404 when absent.

A title must contain 1–120 characters after trimming. done must be a JSON boolean, not the string "true". Request bodies must be JSON objects and are limited to 16 KB. These decisions are explicit so the client knows what to send and what to expect.

2. Prepare the project

mkdir study-task-api
cd study-task-api
npm init -y
npm install express@5

Create app.mjs for the application and server.mjs for the listener. Keeping them separate allows an automated test to import the app without unexpectedly binding port 3000.

3. Implement app.mjs

import express from 'express';
import { randomUUID } from 'node:crypto';

export function createApp() {
  const app = express();
  const tasks = new Map();
  app.disable('x-powered-by');
  app.use(express.json({ limit: '16kb' }));

  function fail(res, status, message) {
    return res.status(status).json({ error: { message } });
  }
  function jsonOnly(req, res, next) {
    if (!req.is('application/json')) return fail(res, 415, 'Use application/json');
    next();
  }
  function validate(req, res, next) {
    const body = req.body;
    if (!body || typeof body !== 'object' || Array.isArray(body)) {
      return fail(res, 400, 'Body must be a JSON object');
    }
    const creating = req.method === 'POST';
    const allowed = creating ? ['title'] : ['title', 'done'];
    const keys = Object.keys(body);
    if (!keys.length || keys.some(key => !allowed.includes(key))) {
      return fail(res, 400, 'Provide only supported fields');
    }
    const clean = {};
    if (creating || Object.hasOwn(.tech-lesson-content, 'title')) {
      if (typeof body.title !== 'string') return fail(res, 400, 'title must be a string');
      const title = body.title.trim();
      if (!title || title.length > 120) return fail(res, 400, 'title must contain 1–120 characters');
      clean.title = title;
    }
    if (Object.hasOwn(.tech-lesson-content, 'done')) {
      if (typeof body.done !== 'boolean') return fail(res, 400, 'done must be a boolean');
      clean.done = body.done;
    }
    req.cleanTask = clean;
    next();
  }
  function loadTask(req, res, next) {
    const id = req.params.id;
    if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) {
      return fail(res, 400, 'Invalid task id');
    }
    const task = tasks.get(id.toLowerCase());
    if (!task) return fail(res, 404, 'Task not found');
    req.task = task;
    next();
  }

  app.get('/health', (req, res) => res.json({ status: 'ok' }));
  app.get('/api/tasks', (req, res) => {
    res.json({ data: Array.from(tasks.values()) });
  });
  app.post('/api/tasks', jsonOnly, validate, (req, res) => {
    if (tasks.size >= 100) return fail(res, 409, 'Practice store is full');
    const task = {
      id: randomUUID(), title: req.cleanTask.title, done: false,
      createdAt: new Date().toISOString()
    };
    tasks.set(task.id, task);
    res.location('/api/tasks/' + task.id).status(201).json({ data: task });
  });
  app.get('/api/tasks/:id', loadTask, (req, res) => {
    res.json({ data: req.task });
  });
  app.patch('/api/tasks/:id', jsonOnly, validate, loadTask, (req, res) => {
    Object.assign(req.task, req.cleanTask);
    res.json({ data: req.task });
  });
  app.delete('/api/tasks/:id', loadTask, (req, res) => {
    tasks.delete(req.task.id);
    res.status(204).end();
  });
  app.use((req, res) => fail(res, 404, 'Route not found'));
  app.use((err, req, res, next) => {
    if (res.headersSent) return next(err);
    const status = err.status === 413 ? 413 :
      err.type === 'entity.parse.failed' ? 400 : 500;
    if (status === 500) console.error('Unexpected application error');
    fail(res, status, status === 500 ? 'Internal server error' : 'Invalid request body');
  });
  return app;
}

4. Start server.mjs

import { createApp } from './app.mjs';
const server = createApp().listen(3000, '127.0.0.1', () => {
  console.log('Study Task API: http://127.0.0.1:3000');
});
server.on('error', error => {
  console.error('Unable to listen:', error.code);
  process.exitCode = 1;
});
process.once('SIGINT', () => server.close());

Run node server.mjs. In an HTTP client, send the following request with the displayed method, URL, Content-Type and body. The UUID and timestamp in the response vary. Copy the returned id into subsequent URLs.

POST http://127.0.0.1:3000/api/tasks
Content-Type: application/json

{"title":"Revise event loop"}

PATCH http://127.0.0.1:3000/api/tasks/PASTE_RETURNED_ID
Content-Type: application/json

{"done":true}

Expected results: create returns 201, done:false and a Location header; patch returns 200 with done:true. GET the Location to read the same task. DELETE it and expect 204 with an empty body. A later GET for that valid ID returns 404. An ID such as abc returns 400, which differs from a correctly formatted but absent UUID.

5. Add a repeatable integration check

Save app.test.mjs and run node --test. This test starts a temporary loopback server on an available port and closes it afterwards. It checks real HTTP behavior, including rejection of an incorrectly typed field.

import test from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { createApp } from './app.mjs';

test('create, reject invalid patch, complete and delete a task', async () => {
  const server = createApp().listen(0, '127.0.0.1');
  try {
    await once(server, 'listening');
    const base = 'http://127.0.0.1:' + server.address().port;
    const created = await fetch(base + '/api/tasks', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: 'Review Node' })
    });
    assert.equal(created.status, 201);
    const { data } = await created.json();
    assert.equal(data.done, false);
    const url = base + '/api/tasks/' + data.id;
    const invalid = await fetch(url, {
      method: 'PATCH', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ done: 'true' })
    });
    assert.equal(invalid.status, 400);
    await invalid.text();
    const changed = await fetch(url, {
      method: 'PATCH', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ done: true })
    });
    assert.equal(changed.status, 200);
    assert.equal((await changed.json()).data.done, true);
    const removed = await fetch(url, { method: 'DELETE' });
    assert.equal(removed.status, 204);
    assert.equal(await removed.text(), '');
    const missing = await fetch(url);
    assert.equal(missing.status, 404);
    await missing.text();
  } finally {
    await new Promise((resolve, reject) =>
      server.close(error => error ? reject(error) : resolve()));
  }
});

6. Review limitations before extending it

The local example has no accounts or durable storage. Do not publish it as a private multi-user task service. Replace Map with a database repository, add schema migrations, and scope every query to the verified owner from the JWT lesson. Add bounded pagination instead of returning every record. Design concurrent update handling, rate limits, HTTPS, safe configuration, operational logs, backups and graceful database shutdown.

Independent practice: add a dueDate field with a documented ISO date format, validate it, and extend the test with a rejected invalid date. Then restart the service and explain why tasks disappear. Persistence should be a deliberate database change rather than an accidental assumption. Finish by writing a short README with run commands, endpoint examples, expected errors and the project's limitations.

📝 Live Lesson Practice

HTML/CSS JavaScript Python C++ C PHP
⌨️ Practice Inputs (लाइव इनपुट भरें) (खाली होने पर RED, भरने पर GREEN underline)
💻 Code Editor (Monaco VS Code Engine)
👀 Live Preview
Address Contact

+91 7877547686

E-mail

onlinecbtportal@gmail.com

Helpline Number

+91 7877547686


Click To Download
Get it on Google Play

ऐप डाउनलोड करने
के लिए Google Play पर
उपलब्ध है

WhatsApp Chat