You have written SQL by hand and an API backed by a JSON file. Now connect the two: a real relational database, embedded in the Node process, with no server to install.
Learning objectives
- Open a SQLite database from Node with
better-sqlite3. - Create tables and seed data at startup.
- Use prepared statements for every query.
- Build a full CRUD API on top of it.
1. Why better-sqlite3
better-sqlite3 is synchronous. That sounds wrong in Node — but SQLite runs in-process, and a local query typically takes microseconds. The synchronous API removes callback noise and is genuinely faster than the async wrappers for this workload.
npm install better-sqlite3 express
const Database = require("better-sqlite3");
const db = new Database("shop.db");
db.pragma("journal_mode = WAL"); // better concurrency for readers
The database is a file next to your code. Add *.db, *.db-wal and *.db-shm to .gitignore.
2. Create the table
db.exec(`
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT NOT NULL,
price INTEGER NOT NULL DEFAULT 0,
category TEXT,
created_at TEXT DEFAULT (datetime('now'))
)
`);
Run schema setup at startup, or keep it in schema.sql and execute that file. Both are fine; what matters is that a fresh clone can create the database in one step.
3. Prepared statements
Prepare once, run many times. The statement is compiled and cached, and parameters are escaped by the driver.
const getAll = db.prepare("SELECT * FROM books ORDER BY id DESC");
const getById = db.prepare("SELECT * FROM books WHERE id = ?");
const getByCategory = db.prepare("SELECT * FROM books WHERE category = ?");
const insert = db.prepare(
"INSERT INTO books (title, author, price, category) VALUES (?, ?, ?, ?)",
);
const update = db.prepare(
"UPDATE books SET title = ?, author = ?, price = ?, category = ? WHERE id = ?",
);
const remove = db.prepare("DELETE FROM books WHERE id = ?");
getAll.all(); // array of rows
getById.get(1); // one row or undefined
insert.run("Clean Code", "Robert C. Martin", 35000, "Programming");
Three methods: .all() for many rows, .get() for one, .run() for writes. .run() returns { changes, lastInsertRowid }.
Named parameters are easier to read when a query has many columns:
const insertNamed = db.prepare(`
INSERT INTO books (title, author, price, category)
VALUES (@title, @author, @price, @category)
`);
insertNamed.run({ title: "Refactoring", author: "Martin Fowler", price: 42000, category: "Programming" });
4. Wiring the API
const express = require("express");
const app = express();
app.use(express.json());
app.get("/books", (req, res) => {
const { category } = req.query;
const rows = category ? getByCategory.all(category) : getAll.all();
res.json({ success: true, count: rows.length, data: rows });
});
app.get("/books/:id", (req, res) => {
const book = getById.get(Number(req.params.id));
if (!book) return res.status(404).json({ success: false, error: "Book not found" });
res.json({ success: true, data: book });
});
app.post("/books", (req, res) => {
const { title, author, price, category } = req.body;
if (!title || !author || !Number.isFinite(Number(price))) {
return res.status(400).json({ success: false, error: "title, author and price are required" });
}
const info = insert.run(title.trim(), author.trim(), Number(price), category ?? null);
const book = getById.get(info.lastInsertRowid);
res.status(201).json({ success: true, data: book });
});
app.listen(3000);
5. Transactions
better-sqlite3 gives you a transaction wrapper that rolls back on any thrown error:
const createOrder = db.transaction((items) => {
const order = insertOrder.run(Date.now(), items.length);
for (const item of items) {
insertItem.run(order.lastInsertRowid, item.productId, item.quantity);
}
return order.lastInsertRowid;
});
createOrder([{ productId: 1, quantity: 2 }]); // all or nothing
Common mistakes
- Building SQL with template strings and user input — that is SQL injection.
- Calling
.get()when the query returns many rows (you silently get the first). - Forgetting
Number()on route params;"1"is not1in SQLite comparison. - Committing the
.dbfile to Git.
Practice
Build the books API in the assignment. Every query must be a prepared statement.