Build a CRUD REST API with Express 5, Sequelize and SQLite, learn how an ORM maps tables to models, and see how to switch to PostgreSQL for production.

Bishal Biswas
Self Learner
What you will learn
- How SQL databases differ from MongoDB, in plain words
- What an ORM is and why it saves you from writing raw SQL for everyday tasks
- How to define a Sequelize model with validation, using SQLite for zero setup
- How to build all five CRUD routes and handle SQL-specific errors
- How to switch the same code to PostgreSQL for production
SQL databases in plain words
A relational database (often called a SQL database) stores data in tables. A table has fixed columns (title, author, pages) and rows (one row per book). You talk to it with SQL (Structured Query Language).
SELECT id, title, author FROM Books WHERE author = 'Frank Herbert';
Compared with MongoDB from the previous lesson:
| MongoDB | SQL database | |
|---|---|---|
| Stores | Documents in collections | Rows in tables |
| Structure | Flexible; Mongoose adds rules | Strict; every row has the same columns |
| Relations | Embedding or references | Foreign keys and joins, a core strength |
| Query language | JavaScript-style objects | SQL |
| Examples | MongoDB | PostgreSQL, MySQL, SQLite, SQL Server |
Neither is “better”. SQL databases shine when your data has many relationships, such as orders, customers and products.
What is an ORM?
An ORM (Object-Relational Mapper) lets you work with database rows as JavaScript objects. You call Book.findByPk(1); the ORM writes the SQL, runs it, and hands you back an object.
Think of it like a translator. You speak JavaScript, the database speaks SQL. The ORM translates in both directions, so you can focus on your app.
This lesson uses Sequelize, a long-standing ORM for Node.js that supports PostgreSQL, MySQL, MariaDB, SQLite and SQL Server.
Why SQLite for learning
SQLite is a SQL database that lives in a single file. There is no server to install or start. That makes it perfect for learning and for small tools. For a production API with many users, you would usually choose PostgreSQL, and you will see at the end how small that switch is.
Step 1: Set up the project
mkdir library-api && cd library-api
npm init -y
npm install express sequelize sqlite3
Note:
sqlite3contains native code. npm normally downloads a prebuilt version for your system. If the install fails, check that you are on a current Node.js LTS version before digging further.
library-api/
package.json
src/
app.js
db.js
server.js
models/
book.js
routes/
books.js
middleware/
error-handler.js
{
"name": "library-api",
"type": "module",
"scripts": {
"dev": "node --watch src/server.js",
"start": "node src/server.js"
}
}
Step 2: Create the Sequelize instance
The Sequelize instance is your connection to the database. Every model is attached to it.
// src/db.js
import { Sequelize } from 'sequelize';
export const sequelize = new Sequelize({
dialect: 'sqlite',
storage: process.env.DB_FILE ?? 'library.sqlite', // the database file
logging: process.env.DB_LOGGING === 'true' ? console.log : false,
});
Setting logging to console.log prints every SQL statement Sequelize runs. Turn it on while learning; it shows you exactly what the ORM does.
Step 3: Define the model
A model represents one table. You list the columns, their types and their rules.
// src/models/book.js
import { DataTypes } from 'sequelize';
import { sequelize } from '../db.js';
export const Book = sequelize.define('Book', {
// Sequelize adds an auto-incrementing "id" primary key for you
title: {
type: DataTypes.STRING(200),
allowNull: false,
validate: {
notNull: { msg: 'Title is required' },
notEmpty: { msg: 'Title cannot be empty' },
},
},
author: {
type: DataTypes.STRING(120),
allowNull: false,
validate: { notNull: { msg: 'Author is required' }, notEmpty: true },
},
pages: {
type: DataTypes.INTEGER,
allowNull: true,
validate: { min: { args: [1], msg: 'Pages must be at least 1' } },
},
isbn: {
type: DataTypes.STRING(20),
allowNull: true,
unique: true, // a database rule: no two books with the same ISBN
},
published: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
});
Sequelize names the table Books (it pluralises the model name) and adds id, createdAt and updatedAt columns automatically.
There are two kinds of rules here:
- Validations (
validate: { ... }) run in JavaScript before Sequelize sends any SQL. - Constraints (
allowNull: false,unique: true) are also written into the table itself, so the database enforces them even if someone bypasses your app.
Step 4: Create the table and start the server
// src/server.js
import app from './app.js';
import { sequelize } from './db.js';
import './models/book.js'; // make sure the model is registered
const port = Number(process.env.PORT) || 3000;
await sequelize.authenticate(); // check the connection works
await sequelize.sync(); // create missing tables
app.listen(port, (error) => {
if (error) throw error;
console.log(`API on http://localhost:${port}`);
});
sequelize.sync() creates any table that does not exist yet. It does not change tables that already exist.
| Call | What it does | Safe? |
|---|---|---|
sync() |
Creates missing tables only | Yes |
sync({ alter: true }) |
Tries to change tables to match models | Risky, can lose data |
sync({ force: true }) |
Drops and recreates every table | Deletes all data |
Warning: In production, do not rely on
sync()to change your tables. Use migrations: small, versioned scripts that change the database step by step, such as “add alanguagecolumn”. Sequelize has a CLI,sequelize-cli, for this.sync()is fine for learning and prototypes.
Step 5: Write the CRUD routes
A small middleware, loadBook, finds the book for /:id routes. It keeps the handlers short and puts the 404 logic in one place.
// src/routes/books.js
import { Router } from 'express';
import { Book } from '../models/book.js';
const router = Router();
const FIELDS = ['title', 'author', 'pages', 'isbn', 'published'];
// Find the book or stop with 400/404; store it on req.book for the next handler
const loadBook = async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id < 1) {
return res.status(400).json({ error: { message: 'Book id must be a positive whole number' } });
}
const book = await Book.findByPk(id);
if (!book) return res.status(404).json({ error: { message: 'Book not found' } });
req.book = book;
next();
};
// CREATE
router.post('/', async (req, res) => {
const book = await Book.create(req.body ?? {}, { fields: FIELDS });
res.status(201).location(`/books/${book.id}`).json({ data: book });
});
// READ ALL
router.get('/', async (req, res) => {
const books = await Book.findAll({ order: [['createdAt', 'DESC']], limit: 50 });
res.json({ data: books });
});
// READ ONE
router.get('/:id', loadBook, (req, res) => {
res.json({ data: req.book });
});
// UPDATE (partial)
router.patch('/:id', loadBook, async (req, res) => {
await req.book.update(req.body ?? {}, { fields: FIELDS });
res.json({ data: req.book });
});
// DELETE
router.delete('/:id', loadBook, async (req, res) => {
await req.book.destroy();
res.status(204).end();
});
export default router;
The fields option is a built-in guard against mass assignment: Sequelize ignores any property not in the list, so a client cannot set id or createdAt.
| Sequelize method | SQL it runs (roughly) |
|---|---|
Book.create(data) |
INSERT INTO Books ... |
Book.findAll({ order, limit }) |
SELECT ... ORDER BY ... LIMIT ... |
Book.findByPk(1) |
SELECT ... WHERE id = 1 |
book.update(data) |
UPDATE Books SET ... WHERE id = 1 |
book.destroy() |
DELETE FROM Books WHERE id = 1 |
res.json(book) works because Sequelize model instances have a toJSON() method that returns their plain column values.
Step 6: Handle Sequelize errors
Sequelize throws its own error classes. Two are the client’s fault:
UniqueConstraintError: a duplicate value in auniquecolumn. Respond409 Conflict.ValidationError: a validation ornotNullrule failed. Respond400.
// src/middleware/error-handler.js
import { UniqueConstraintError, ValidationError } from 'sequelize';
const toDetails = (err) => err.errors.map((e) => ({ field: e.path, message: e.message }));
export const errorHandler = (err, req, res, next) => {
if (res.headersSent) return next(err);
// Check this one first: UniqueConstraintError is a kind of ValidationError
if (err instanceof UniqueConstraintError) {
return res.status(409).json({ error: { message: 'Duplicate value', details: toDetails(err) } });
}
if (err instanceof ValidationError) {
return res.status(400).json({ error: { message: 'Validation failed', details: toDetails(err) } });
}
if (err.type === 'entity.parse.failed') {
return res.status(400).json({ error: { message: 'Request body is not valid JSON' } });
}
console.error(err);
res.status(500).json({ error: { message: 'Something went wrong' } });
};
Note: The order of
instanceofchecks matters.UniqueConstraintErrorextendsValidationError, so if you checkedValidationErrorfirst, duplicates would come back as400instead of409.
Step 7: Wire up the app
// src/app.js
import express from 'express';
import booksRouter from './routes/books.js';
import { errorHandler } from './middleware/error-handler.js';
const app = express();
app.use(express.json());
app.use('/books', booksRouter);
app.use((req, res) => {
res.status(404).json({ error: { message: 'Route not found' } });
});
app.use(errorHandler);
export default app;
Step 8: Test it
Start the server with SQL logging turned on (inline environment variables like this work in bash):
DB_LOGGING=true npm run dev
# Create
curl -i -X POST http://localhost:3000/books \
-H "Content-Type: application/json" \
-d '{"title":"Dune","author":"Frank Herbert","pages":412,"isbn":"9780441013593"}'
# Read
curl http://localhost:3000/books
curl http://localhost:3000/books/1
# Update
curl -X PATCH http://localhost:3000/books/1 \
-H "Content-Type: application/json" \
-d '{"published":true}'
# Delete
curl -i -X DELETE http://localhost:3000/books/1
The create response:
{
"data": {
"id": 1,
"title": "Dune",
"author": "Frank Herbert",
"pages": 412,
"isbn": "9780441013593",
"published": false,
"createdAt": "2026-10-25T09:35:20.512Z",
"updatedAt": "2026-10-25T09:35:20.512Z"
}
}
The exact order of the keys may differ on your machine. That is normal: JSON objects have no fixed key order.
In the server terminal you see the SQL that Sequelize generated, something like:
Executing (default): INSERT INTO `Books` (`title`,`author`,`pages`,`isbn`,`published`,`createdAt`,`updatedAt`) VALUES ($1,$2,$3,$4,$5,$6,$7);
The $1, $2 placeholders are important. Sequelize sends your values separately from the SQL text, so a title like '; DROP TABLE Books; -- is stored as plain text, never run as SQL. This protection is called parameterised queries, and it is the main defence against SQL injection.
Error cases to try:
curl -X POST http://localhost:3000/books -H "Content-Type: application/json" -d '{"author":"Anon"}' # 400
curl http://localhost:3000/books/abc # 400
curl http://localhost:3000/books/999 # 404
When you need raw SQL
ORMs cover everyday queries. For complex reports you may want to write SQL yourself. Use replacements so values are still escaped safely:
// src/routes/stats.js
import { Router } from 'express';
import { QueryTypes } from 'sequelize';
import { sequelize } from '../db.js';
const router = Router();
// GET /stats/authors?published=true
router.get('/authors', async (req, res) => {
const published = req.query.published === 'true';
const rows = await sequelize.query(
'SELECT author, COUNT(*) AS total FROM Books WHERE published = ? GROUP BY author',
{ replacements: [published], type: QueryTypes.SELECT }, // "?" is filled in safely
);
res.json({ data: rows });
});
export default router;
Warning: Never build SQL by gluing strings together, like
`... WHERE author = '${req.query.author}'`. That is exactly how SQL injection happens.
Switching to PostgreSQL
Because your routes only talk to Sequelize, moving to PostgreSQL mostly means changing db.js:
npm install pg pg-hstore
# .env
DATABASE_URL=postgres://app_user:change-me@localhost:5432/library
// src/db.js (PostgreSQL version)
import { Sequelize } from 'sequelize';
export const sequelize = new Sequelize(process.env.DATABASE_URL, {
logging: false,
});
Load the .env file with node --env-file=.env as in earlier lessons. Your models and routes stay the same.
Common mistakes
- Using
sync({ force: true })and losing data. It drops every table. Keep it out of real projects. - Forgetting to import the model before
sync(). Sequelize only creates tables for models it knows about. - Checking
ValidationErrorbeforeUniqueConstraintError. Duplicates then look like ordinary validation errors. - Passing
req.bodywithoutfields. Clients could set columns they should not touch. - String-building SQL. Always let Sequelize parameterise values, or use
replacements.
Key takeaways
- SQL databases store rows in tables with fixed columns. An ORM like Sequelize maps those rows to JavaScript objects.
- SQLite needs no server, which makes it ideal for learning; PostgreSQL is a common production choice.
- Define models with types, validations and constraints. Use
sync()while learning and migrations in production. - CRUD maps to
create,findAll/findByPk,instance.updateandinstance.destroy. Use thefieldsoption to whitelist columns. - Map
UniqueConstraintErrorto409andValidationErrorto400, checking the unique error first.
Practice
- Add a
yearcolumn (integer, between 1450 and the current year) and recreate the database file to see it appear. - Add
GET /books?published=truethat filters withBook.findAll({ where: { published: true } }). - Create an
Authormodel and connect it withAuthor.hasMany(Book)andBook.belongsTo(Author). Then useinclude: Authorwhen loading a book. - Turn on SQL logging and write down the SQL produced by each of your five routes.
What’s next
Your list routes currently return at most 50 rows in a fixed order. Learn to let clients choose pages, filters and sort order in Pagination, Filtering and Sorting.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready