Skip to content
Laviyx Learn

Try

Node.js and Express course menu

Node.js Project: Build a Notes REST API with Express and SQLite

Build a complete notes API step by step: project setup, a SQLite database module, validation with Zod, CRUD routes with search, tags and pagination, consistent errors, automated tests with node:test and Supertest, and ideas for extending it.

Intermediate14 min readUpdated
On this page

This project brings the course together into a complete, tested API for personal notes: create, list, search, tag, update and delete notes, stored in SQLite. Build it file by file.

Step 1: Setup#

Terminal
npm init -y
npm pkg set type=module
npm install express zod
npm install -D supertest

The finished layout:

Text
notes-api/
├── package.json
├── src/
│   ├── db.js        database connection and schema
│   ├── app.js       the Express app (routes, validation, errors)
│   └── server.js    starts the server
└── test/
    └── notes.test.js

Step 2: The database module#

src/db.js (JavaScript)
import { DatabaseSync } from "node:sqlite";

export function openDatabase(file = "notes.db") {
  const db = new DatabaseSync(file);
  db.exec(`
    CREATE TABLE IF NOT EXISTS notes (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      title TEXT NOT NULL,
      body TEXT NOT NULL DEFAULT '',
      tags TEXT NOT NULL DEFAULT '[]',
      pinned INTEGER NOT NULL DEFAULT 0
    )
  `);
  return db;
}

export function toNote(row) {
  return { id: row.id, title: row.title, body: row.body, tags: JSON.parse(row.tags), pinned: row.pinned === 1 };
}

Tags are stored as a JSON array in a text column, which keeps the schema simple; a larger app would use a separate tags table. toNote converts database rows into the shape the API returns. Passing ":memory:" as the file gives each test its own empty database.

Step 3: The app#

src/app.js (JavaScript)
import express from "express";
import { z } from "zod";
import { toNote } from "./db.js";

const NoteInput = z.object({
  title: z.string().trim().min(1).max(120),
  body: z.string().max(10_000).default(""),
  tags: z.array(z.string().trim().toLowerCase().min(1).max(30)).max(10).default([]),
  pinned: z.boolean().default(false),
});

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", result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`));
  }
  return result.data;
}

export function createApp(db) {
  const app = express();
  app.use(express.json({ limit: "50kb" }));

  const getRow = (id) => {
    const row = db.prepare("SELECT * FROM notes WHERE id = ?").get(id);
    if (!row) throw new HttpError(404, "Note not found");
    return row;
  };

  // List: ?q=search&tag=work&page=1&limit=10 (pinned notes first)
  app.get("/api/notes", (req, res) => {
    const { q = "", tag = "" } = req.query;
    const page = Math.max(1, Number(req.query.page) || 1);
    const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 10));
    const where = "WHERE (title LIKE :q OR body LIKE :q) AND (:tag = '' OR EXISTS (SELECT 1 FROM json_each(tags) WHERE value = :tag))";
    const params = { q: `%${q}%`, tag: tag.toLowerCase() };
    const total = db.prepare(`SELECT COUNT(*) AS n FROM notes ${where}`).get(params).n;
    const rows = db
      .prepare(`SELECT * FROM notes ${where} ORDER BY pinned DESC, id DESC LIMIT :limit OFFSET :offset`)
      .all({ ...params, limit, offset: (page - 1) * limit });
    res.json({ data: rows.map(toNote), page, limit, total });
  });

  app.get("/api/notes/:id", (req, res) => {
    res.json({ data: toNote(getRow(Number(req.params.id))) });
  });

  app.post("/api/notes", (req, res) => {
    const note = parse(NoteInput, req.body);
    const { lastInsertRowid } = db
      .prepare("INSERT INTO notes (title, body, tags, pinned) VALUES (?, ?, ?, ?)")
      .run(note.title, note.body, JSON.stringify(note.tags), note.pinned ? 1 : 0);
    res.status(201).json({ data: toNote(getRow(lastInsertRowid)) });
  });

  app.patch("/api/notes/:id", (req, res) => {
    const current = toNote(getRow(Number(req.params.id)));
    const changes = parse(NoteInput.partial(), req.body);
    const next = { ...current, ...Object.fromEntries(Object.entries(changes).filter(([key]) => key in req.body)) };
    db.prepare("UPDATE notes SET title = ?, body = ?, tags = ?, pinned = ? WHERE id = ?")
      .run(next.title, next.body, JSON.stringify(next.tags), next.pinned ? 1 : 0, current.id);
    res.json({ data: next });
  });

  app.delete("/api/notes/:id", (req, res) => {
    getRow(Number(req.params.id));
    db.prepare("DELETE FROM notes WHERE id = ?").run(Number(req.params.id));
    res.sendStatus(204);
  });

  app.use((req, res) => res.status(404).json({ error: "Route not found" }));

  app.use((err, req, res, next) => {
    if (err.type === "entity.parse.failed") return res.status(400).json({ error: "Invalid JSON" });
    const status = err.status ?? 500;
    if (status >= 500) console.error(err);
    res.status(status).json({ error: status >= 500 ? "Internal server error" : err.message, ...(err.details && { details: err.details }) });
  });

  return app;
}

Highlights:

  • Every value goes into SQL through named placeholders (:q, :tag), including the search text.
  • SQLite's json_each lets the query look inside the stored tags array.
  • In PATCH, only fields actually sent in the body are changed (key in req.body), so schema defaults never overwrite existing values.
  • Expected errors are thrown as HttpError, and Express 5 forwards them to one error handler.

Step 4: The server#

src/server.js (JavaScript)
import { createApp } from "./app.js";
import { openDatabase } from "./db.js";

const port = Number(process.env.PORT ?? 3000);
createApp(openDatabase()).listen(port, () => console.log(`Notes API on http://localhost:${port}`));
Terminal
node src/server.js
Output
Notes API on http://localhost:3000

Step 5: Try it#

Terminal
curl -s -X POST localhost:3000/api/notes -H "Content-Type: application/json" -d '{"title":"Groceries","body":"Rice, dal, tomatoes","tags":["Home","shopping"]}'
curl -s -X POST localhost:3000/api/notes -H "Content-Type: application/json" -d '{"title":"Sprint review","body":"Demo the notes API","tags":["work"],"pinned":true}'
curl -s -X POST localhost:3000/api/notes -H "Content-Type: application/json" -d '{"title":"Book list","body":"The Guide, Godan","tags":["reading"]}'
curl -s -X POST localhost:3000/api/notes -H "Content-Type: application/json" -d '{"title":"","tags":"work"}'
Output
{"data":{"id":1,"title":"Groceries","body":"Rice, dal, tomatoes","tags":["home","shopping"],"pinned":false}}
{"data":{"id":2,"title":"Sprint review","body":"Demo the notes API","tags":["work"],"pinned":true}}
{"data":{"id":3,"title":"Book list","body":"The Guide, Godan","tags":["reading"],"pinned":false}}
{"error":"Validation failed","details":["title: Too small: expected string to have >=1 characters","tags: Invalid input: expected array, received string"]}
Terminal
curl -s "localhost:3000/api/notes?limit=2"
curl -s "localhost:3000/api/notes?q=dal"
curl -s "localhost:3000/api/notes?tag=WORK"
curl -s -X PATCH localhost:3000/api/notes/1 -H "Content-Type: application/json" -d '{"pinned":true}'
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE localhost:3000/api/notes/3
curl -s localhost:3000/api/notes/3
Output
{"data":[{"id":2,"title":"Sprint review","body":"Demo the notes API","tags":["work"],"pinned":true},{"id":3,"title":"Book list","body":"The Guide, Godan","tags":["reading"],"pinned":false}],"page":1,"limit":2,"total":3}
{"data":[{"id":1,"title":"Groceries","body":"Rice, dal, tomatoes","tags":["home","shopping"],"pinned":false}],"page":1,"limit":10,"total":1}
{"data":[{"id":2,"title":"Sprint review","body":"Demo the notes API","tags":["work"],"pinned":true}],"page":1,"limit":10,"total":1}
{"data":{"id":1,"title":"Groceries","body":"Rice, dal, tomatoes","tags":["home","shopping"],"pinned":true}}
204
{"error":"Note not found"}

Pinned notes come first, then the newest. Tags were lower-cased on the way in, so tag=WORK matches "work". The PATCH changed only pinned and kept the other fields. Because the data lives in notes.db, it survives server restarts.

Step 6: Tests#

test/notes.test.js (JavaScript)
import assert from "node:assert/strict";
import { beforeEach, test } from "node:test";
import request from "supertest";
import { createApp } from "../src/app.js";
import { openDatabase } from "../src/db.js";

let app;
beforeEach(() => {
  app = createApp(openDatabase(":memory:"));        // a fresh, empty database for every test
});

const create = (note) => request(app).post("/api/notes").send(note);

test("creates a note with defaults", async () => {
  const res = await create({ title: "Hello" });
  assert.equal(res.status, 201);
  assert.deepEqual(res.body.data, { id: 1, title: "Hello", body: "", tags: [], pinned: false });
});

test("validates input", async () => {
  const res = await create({ title: "", tags: "work" });
  assert.equal(res.status, 400);
  assert.equal(res.body.details.length, 2);
});

test("searches title and body, and filters by tag", async () => {
  await create({ title: "Groceries", body: "rice and dal", tags: ["home"] });
  await create({ title: "Dal recipe", tags: ["cooking"] });
  await create({ title: "Taxes", tags: ["home"] });
  const search = await request(app).get("/api/notes?q=dal");
  assert.equal(search.body.total, 2);
  const home = await request(app).get("/api/notes?tag=home");
  assert.deepEqual(home.body.data.map((n) => n.title), ["Taxes", "Groceries"]);
});

test("pinned notes are listed first", async () => {
  await create({ title: "Old but pinned", pinned: true });
  await create({ title: "Newer" });
  const res = await request(app).get("/api/notes");
  assert.equal(res.body.data[0].title, "Old but pinned");
});

test("patch changes only the given fields", async () => {
  await create({ title: "Draft", body: "keep me", tags: ["x"] });
  const res = await request(app).patch("/api/notes/1").send({ title: "Final" });
  assert.deepEqual(res.body.data, { id: 1, title: "Final", body: "keep me", tags: ["x"], pinned: false });
});

test("deleted notes are gone", async () => {
  await create({ title: "Temp" });
  assert.equal((await request(app).delete("/api/notes/1")).status, 204);
  assert.equal((await request(app).get("/api/notes/1")).status, 404);
});
Terminal
node --test
Output
✔ creates a note with defaults (61.3018ms)
✔ validates input (9.2136ms)
✔ searches title and body, and filters by tag (24.7796ms)
✔ pinned notes are listed first (14.9396ms)
✔ patch changes only the given fields (9.9392ms)
✔ deleted notes are gone (13.6577ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 567.7694

Ideas to extend it#

  • Users and authentication: add the JWT login and a user_id column, so each user sees only their notes.
  • Timestamps: created_at and updated_at columns, sorted by last update.
  • Full-text search with SQLite's FTS5 extension.
  • PostgreSQL in production, with migrations.
  • A React front end using Fetching Data.

Key takeaway#

Next project: URL Shortener.

Finished this lesson?