Capstone: A REST API

Reviewed & published by Brayan K

A capstone is a project that ties everything together — here, a complete tasks REST API that combines config, a SQLite database, Express routes, middleware, and a test into one small, well-structured application.

Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

By the end of this lesson you'll have a project laid out across db.js, app.js, and server.js, with CRUD endpoints, a logger and error handler, environment-driven config, and a test — the shape of a real Node back-end.

What You'll Build in This Capstone

1️⃣ The Data Layer (db.js)

Start with the database. db.js opens a SQLite file whose path comes from the environment, creates the tasks table, and exposes a handful of small, focused query functions that use ? placeholders. Keeping data access here means routes never write raw SQL.

// CAPSTONE: a complete "tasks" REST API. We'll combine everything from the
// course — config, a database, Express routes, middleware, and a test.
// This first file is the data layer: open the DB and expose helper functions.

// --- db.js ---
const { DatabaseSync } = require("node:sqlite");

// Read the database path from the environment (with a default) — config lesson.
const db = new DatabaseSync(process.env.DB_FILE ?? "tasks.db");

db.exec(`
  CREATE TABLE IF NOT EXISTS tasks (
    id    INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    done  INTEGER NOT NULL DEFAULT 0
  )
`);

// Small, testable functions — each does ONE thing, using ? placeholders.
const queries = {
  all:    () => db.prepare("SELECT id, title, done FROM tasks").all(),
  get:    (id) => db.prepare("SELECT id, title, done FROM tasks WHERE id = ?").get(id),
  create: (title) => db.prepare("INSERT INTO tasks (title) VALUES (?)").run(title),
  remove: (id) => db.prepare("DELETE FROM tasks WHERE id = ?").run(id),
};

module.exports = { db, queries };

console.log("db.js loaded — tasks table ready.");

2️⃣ The HTTP Layer (app.js)

Next, the Express app. app.js wires up JSON parsing, a request logger, the CRUD routes (each calling a db.js helper), and a catch-all error handler. Crucially it exports the app without calling listen() — so tests can import it.

// --- app.js --- the HTTP layer: routes + middleware that use db.js.
const express = require("express");
const { queries } = require("./db");

const app = express();
app.use(express.json());                 // parse JSON bodies (middleware lesson)

// Request logger — runs for every request.
app.use((req, res, next) => {
  console.log(`${req.method} ${req.url}`);
  next();
});

// READ all
app.get("/tasks", (req, res) => {
  res.json(queries.all());
});

// READ one (404 if missing)
app.get("/tasks/:id", (req, res) => {
  const task = queries.get(Number(req.params.id));
  if (!task) return res.status(404).json({ error: "Not found" });
  res.json(task);
});

// CREATE (validate, then 201 with the new resource)
app.post("/tasks", (req, res) => {
  if (!req.body.title) return res.status(400).json({ error: "title required" });
  const result = queries.create(req.body.title);
  res.status(201).json({ id: Number(result.lastInsertRowid), title: req.body.title, done: 0 });
});

// DELETE (204 on success, 404 if missing)
app.delete("/tasks/:id", (req, res) => {
  const id = Number(req.params.id);
  if (!queries.get(id)) return res.status(404).json({ error: "Not found" });
  queries.remove(id);
  res.status(204).end();
});

// Catch-all error handler — four arguments (middleware lesson).
app.use((err, req, res, next) => {
  console.error(err.message);
  res.status(500).json({ error: "server error" });
});

module.exports = app;   // export so tests can import it without starting a server

3️⃣ Starting Up and Testing

Finally, server.js imports the app and starts listening on a port from the environment, and a small test file checks the data layer. Separating "define the app" from "start the server" is what makes the whole thing testable.

// --- server.js --- the entry point that actually starts listening.
const app = require("./app");
const port = Number(process.env.PORT ?? 3000);   // config lesson

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

// --- tasks.test.js --- one quick test of the data layer (testing lesson).
// const { describe, it } = require("node:test");
// const assert = require("node:assert");
// const { queries } = require("./db");
//
// describe("tasks db", () => {
//   it("creates and reads a task", () => {
//     const r = queries.create("write tests");
//     const task = queries.get(Number(r.lastInsertRowid));
//     assert.strictEqual(task.title, "write tests");
//   });
// });

// Run the server:   node server.js
// Run the tests:    node --test

Your turn. Fill in the two ___ blanks to add an "update" route that marks a task done, then run it.

// 🎯 YOUR TURN — add an "update" feature to the capstone API.
// Add this route to app.js, then run: node server.js

// PUT /tasks/:id/done should mark a task as completed (set done = 1).
app.put("/tasks/:id/done", (req, res) => {
  const id = Number(req.params.id);

  // TODO 1: if queries.get(id) returns nothing, respond 404 and return.
  if (!queries.get(id)) return res.status(___).json({ error: "Not found" });  // 👉 the "not found" code

  // Mark it done (a small query you'd add to db.js):
  //   markDone: (id) => db.prepare("UPDATE tasks SET done = 1 WHERE id = ?").run(id)
  queries.markDone(id);

  // TODO 2: respond with the updated task as JSON.
  res.json(queries.___(id));   // 👉 the helper that fetches one task
});

// ✅ PUT /tasks/1/done  ->  {"id":1,"title":"...","done":1}

Mini-Challenge: Make It Production-Shaped

Extend the capstone with a query-param filter, a /health route, a second test, and a .env file. Run it with node --env-file=.env server.js.

// 🎯 MINI-CHALLENGE: extend the capstone into a real project.
// Build on the files above (db.js, app.js, server.js) and add:
//
// 1. A "filter" query param:  GET /tasks?done=true returns only completed
//    tasks. (Hint: read req.query.done and filter the results.)
// 2. A health route:  GET /health  ->  200 {"status":"ok"} so monitors can
//    check the server is alive.
// 3. A second test file asserting GET /health returns status "ok"
//    (import app, but you can test queries directly without HTTP).
// 4. A .env file with PORT and DB_FILE, run with:
//    node --env-file=.env server.js
//
// ✅ Goal: a small but complete, tested, configurable REST API you can show off.

// your code here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Project Layout

FileResponsibility
db.jsOpen the database, export query helpers
app.jsMiddleware + routes; export the app
server.jsImport app, call listen() on PORT
*.test.jsTests run by node --test
.envPORT, DB_FILE (kept out of git)

Frequently Asked Questions

Congratulations — you've finished the entire Node.js course!

You started by running your very first console.log outside the browser, and you've finished by building a complete, tested REST API. Along the way you mastered modules and npm, the event loop and async/await, the file system, HTTP servers, Express routing and middleware, a real database, environment config, and the built-in test runner.

🚀 What's next? Rebuild this API from scratch without looking, then add authentication, pagination, and a deploy. You now have every tool a Node back-end developer needs — go build and ship something real.

Practice quiz

Why does app.js export the Express app without calling listen()?

  • Because listen() does not exist in Express
  • To make the app run faster
  • So tests can import the app without starting a real server
  • Because Express forbids it

Answer: So tests can import the app without starting a real server. Keeping app.listen() out of app.js means tests can import the app and exercise routes without opening a port.

Which file actually starts the server listening on a port?

  • server.js
  • app.js
  • db.js
  • tasks.test.js

Answer: server.js. server.js imports the app and calls app.listen(), separating 'define the app' from 'start the server'.

What is the job of db.js in this capstone?

  • Start the HTTP server
  • Parse JSON request bodies
  • Generate JWTs
  • The data layer: open the DB and expose small query helpers

Answer: The data layer: open the DB and expose small query helpers. db.js opens the database and exports small, testable query functions, keeping data access in one module.

Where does the database file path come from?

  • A hard-coded absolute path
  • process.env.DB_FILE with a default
  • A command-line prompt
  • It is random each run

Answer: process.env.DB_FILE with a default. The path is read from the environment, process.env.DB_FILE ?? 'tasks.db', applying the config lesson.

What status code does a successful POST /tasks return?

  • 201 Created
  • 200 OK
  • 204 No Content
  • 404 Not Found

Answer: 201 Created. Creating a resource returns 201 with the new resource in the body.

What does GET /tasks/:id return when the task is missing?

  • 200 with null
  • 500 server error
  • 404 with a Not found error
  • An empty 204

Answer: 404 with a Not found error. If queries.get returns nothing, the route responds 404 with { error: 'Not found' }.

How many arguments does the catch-all Express error handler take?

  • Two: req, res
  • Four: err, req, res, next
  • Three: req, res, next
  • One: err

Answer: Four: err, req, res, next. Express recognizes an error-handling middleware by its four parameters (err, req, res, next).

What status code does a successful DELETE return here?

  • 200 OK
  • 201 Created
  • 202 Accepted
  • 204 No Content

Answer: 204 No Content. The delete route ends with res.status(204).end() to signal success with no body.

Why use ? placeholders in the SQL queries?

  • To make queries shorter
  • To safely bind values and avoid SQL injection
  • Because SQLite requires them
  • To speed up the server start

Answer: To safely bind values and avoid SQL injection. Parameterized ? placeholders bind values safely instead of concatenating user input into SQL.

Which middleware parses incoming JSON request bodies?

  • express.static()
  • express.urlencoded() only
  • express.json()
  • body-parser.raw()

Answer: express.json(). app.use(express.json()) parses JSON bodies so req.body is populated for POST routes.

Continue this course

Frequently asked questions

How should I split a Node project into files?

Separate by responsibility. A common, clean layout puts your data access in one module (db.js with small query functions), your HTTP routes and middleware in another (app.js that exports the Express app), and the line that actually starts the server in a third (server.js). Keeping app.js free of app.listen() means your tests can import the app and exercise routes without opening a real port. Each file does one job, which makes the code easier to read, test, and change without breaking unrelated parts.

Why export the Express app instead of just calling app.listen()?

Because it makes the app testable and reusable. If app.js both defines routes and starts listening, importing it in a test would immediately bind to a port and run the server — awkward and slow. By exporting the configured app and moving app.listen() into a separate server.js entry point, your tests can import the app object and assert on its behavior directly, and tools like supertest can drive requests in-memory. It also lets you reuse the same app in different contexts, such as serverless wrappers.

Where does configuration like the port and database file come from?

From environment variables, exactly as in the config lesson. The capstone reads process.env.PORT (defaulting to 3000) and process.env.DB_FILE (defaulting to tasks.db), so the same code runs locally and in production by just changing the environment. You can keep those values in a .env file and launch with node --env-file=.env server.js. This keeps machine-specific details and secrets out of your source code and out of version control.

What should I build next to keep improving?

Rebuild this API from memory without looking, then extend it: add input validation for more fields, pagination for the list endpoint, an authentication middleware that checks an API key or token, and a richer test suite covering the error paths. After that, try a different data store like PostgreSQL, deploy it to a hosting platform, and connect a small front-end. The fastest way to cement these skills is to ship a real project end to end — the patterns you used here scale up to production apps.