π οΈ Weekend Project: Express.Js
This is your capstone for the Express module: a weekend-sized build where everything you've learned β routing, middleware, JWT auth, validation, and centralized error handling β comes together into one coherent API. Instead of a wall of code to copy, you'll work through clear milestones, checking each one off before moving on.
π― Learning Objectives
By the end of this project, you will be able to:
- Structure an Express app with a routes β controllers β services β models separation
- Implement JWT authentication and role-based authorization as reusable middleware
- Wire up centralized error handling with a custom error class hierarchy
- Validate incoming requests and translate framework/database errors into clean JSON responses
- Judge your own work against an explicit definition of done
Estimated Time: 6β10 hours (a real weekend) β’ Difficulty: Intermediate
Hands-on: Build the "Bookshelf API" end to end, one milestone at a time, then self-assess against the checklist.
In This Lesson
The Project & How to Attack It
You've spent this module learning Express one concept at a time. A weekend project is where those pieces stop being isolated demos and start being an application. The goal isn't to type the most lines of code β it's to ship something small that is structured the way real backends are structured, so that adding a feature later is a five-minute job rather than a rewrite.
The single most useful habit for a build like this is to work in vertical slices: get one complete request flowing end to end (route β controller β service β model β database β response) before you add the next. A half-finished feature that actually runs teaches you more than four features that only exist as files.
π Vertical slice
A vertical slice is a thin, working path through every layer of the app for a single behavior β for example, "register a user" touching the route, controller, service, model, and database. You build the app as a stack of slices, each shippable on its own.
π‘ Rule of thumb: if you can't run the app and hit an endpoint after an hour, you've gone too wide. Narrow the scope until something responds, then grow from there.
The Brief: Bookshelf API
You're building a RESTful API for a personal Bookshelf. A user can register, log in, and manage their own collection of books. Every book belongs to exactly one user, and users can only see and edit their own books. That single ownership rule is what forces you to combine authentication and authorization β the interesting part of the exercise.
Endpoints you'll implement
| Endpoint | Method | Description | Access |
|---|---|---|---|
/api/auth/register | POST | Create an account | Public |
/api/auth/login | POST | Log in, receive a JWT | Public |
/api/auth/me | GET | Current user's profile | Private |
/api/books | GET / POST | List your books / add a book | Private |
/api/books/:id | GET / PUT / DELETE | Manage one of your books | Private |
π‘ Scope it honestly
Those five routes are the core. Collections, reviews, recommendations, and search are stretch goals β reach them only after the core is done and tested. A finished small API beats an unfinished big one every time.
Tech choices
- Runtime/framework: Node.js + Express 5
- Database: MongoDB via Mongoose (an in-memory Mongo is fine for development and tests)
- Auth: JSON Web Tokens (JWT) with password hashing via
bcryptjs - Validation:
express-validator - Structure: MVC plus a service layer for business logic
β Note on Express 5
Express 5 changed one thing that matters here: async route handlers that reject or throw are now forwarded to your error middleware automatically. The asyncHandler wrapper shown later is still a fine, explicit habit (and keeps the code portable to Express 4), but on Express 5 a thrown error in an async handler will reach your central handler even without it.
The Architecture You're Building
Every request travels the same path. Keeping these responsibilities separate is the whole point: controllers speak HTTP, services hold business rules, models talk to the database, and one error handler catches everything that goes wrong.
auth Β· validate] C --> D[Controller] D --> E[Service] E --> F[Model] F --> G[(MongoDB)] D -->|throws| H[Error Handler] E -->|throws| H H --> A D --> A
Folder layout
bookshelf-api/
βββ src/
β βββ config/ # db connection, env, logger
β βββ controllers/ # HTTP in/out only
β βββ errors/ # AppError, error types, asyncHandler, errorHandler
β βββ middleware/ # auth, validate
β βββ models/ # User, Book (Mongoose schemas)
β βββ routes/ # authRoutes, bookRoutes
β βββ services/ # authService, bookService (business logic)
β βββ utils/ # validation schemas
β βββ app.js # build the Express app (no listen here)
β βββ server.js # connect DB, then app.listen(...)
βββ tests/ # unit + integration tests
βββ .env # secrets β never commit this
βββ .gitignore
βββ package.json
β οΈ Keep app and server separate
Put the Express app in app.js and export it without calling listen(). Do the actual connect + listen in server.js. This one split is what lets Supertest import your app for integration tests without ever opening a real port.
Milestones (Your Weekend Plan)
Do these in order. Each one ends with something you can run and verify, so you always have a working app to fall back to.
+ Error Core] --> M2[M2 Β· Auth] M2 --> M3[M3 Β· Books CRUD] M3 --> M4[M4 Β· Validation
+ Polish] M4 --> S[Stretch goals]
| Milestone | You're done when⦠| Rough time |
|---|---|---|
| M1 β Skeleton & Error Core | The server boots, an unknown route returns a clean 404 JSON, and thrown errors reach one handler. | 1β2 h |
| M2 β Auth | You can register, log in, get a JWT, and reach a protected route only with a valid token. | 2β3 h |
| M3 β Books CRUD | A logged-in user can create, list, read, update, and delete their own books β and is blocked from others'. | 2β3 h |
| M4 β Validation & Polish | Bad input returns a structured 400, and Mongoose/JWT errors are mapped to friendly responses. | 1β2 h |
Milestone 1 β Skeleton & Error Core
Start with the plumbing. Getting error handling in place first means every later feature can simply throw and trust that the response comes out clean.
The app factory
// src/app.js
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const morgan = require('morgan');
const { notFoundHandler, errorHandler } = require('./errors/errorHandler');
function createApp() {
const app = express();
// Core middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(helmet());
app.use(cors());
if (process.env.NODE_ENV !== 'test') app.use(morgan('dev'));
// Routes (added in later milestones)
app.use('/api/auth', require('./routes/authRoutes'));
app.use('/api/books', require('./routes/bookRoutes'));
// 404 for anything unmatched, then the central error handler LAST
app.use(notFoundHandler);
app.use(errorHandler);
return app;
}
module.exports = createApp;
// src/server.js
require('dotenv').config();
const mongoose = require('mongoose');
const createApp = require('./app');
const PORT = process.env.PORT || 5000;
async function start() {
await mongoose.connect(process.env.MONGO_URI);
console.log('MongoDB connected');
createApp().listen(PORT, () => console.log(`API on http://localhost:${PORT}`));
}
start().catch((err) => {
console.error('Failed to start:', err);
process.exit(1);
});
The error core
A tiny class hierarchy lets each part of the app describe what went wrong without knowing how it will be rendered.
base class] --> B[NotFoundError Β· 404] A --> C[ValidationError Β· 400] A --> D[AuthenticationError Β· 401] A --> E[AuthorizationError Β· 403] A --> F[ConflictError Β· 409]
// src/errors/AppError.js
class AppError extends Error {
constructor(message, statusCode, code, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.code = code || this.constructor.name;
this.isOperational = isOperational; // true = safe to show the user
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
// src/errors/errorTypes.js
const AppError = require('./AppError');
class NotFoundError extends AppError {
constructor(resource = 'Resource', id = '') {
super(id ? `${resource} ${id} not found` : `${resource} not found`, 404, 'NOT_FOUND');
}
}
class ValidationError extends AppError {
constructor(message = 'Validation failed', details = null) {
super(message, 400, 'VALIDATION_ERROR');
this.details = details;
}
}
class AuthenticationError extends AppError {
constructor(message = 'Authentication failed') { super(message, 401, 'AUTH_ERROR'); }
}
class AuthorizationError extends AppError {
constructor(message = 'Not authorized') { super(message, 403, 'FORBIDDEN'); }
}
class ConflictError extends AppError {
constructor(message = 'Resource already exists') { super(message, 409, 'CONFLICT'); }
}
module.exports = { NotFoundError, ValidationError, AuthenticationError, AuthorizationError, ConflictError };
// src/errors/asyncHandler.js
// Explicit wrapper so rejected promises reach next(). (Optional on Express 5.)
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
module.exports = asyncHandler;
// src/errors/errorHandler.js
const AppError = require('./AppError');
const notFoundHandler = (req, res, next) =>
next(new AppError(`Route not found: ${req.originalUrl}`, 404, 'ROUTE_NOT_FOUND'));
const errorHandler = (err, req, res, next) => {
let error = err;
// Translate common non-AppError failures into AppErrors
if (err.name === 'CastError')
error = new AppError(`Invalid ${err.path}: ${err.value}`, 400, 'INVALID_ID');
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
error = new AppError(`Duplicate value for ${field}`, 409, 'DUPLICATE');
}
if (err.name === 'JsonWebTokenError')
error = new AppError('Invalid token', 401, 'INVALID_TOKEN');
if (err.name === 'TokenExpiredError')
error = new AppError('Token expired', 401, 'TOKEN_EXPIRED');
const statusCode = error.statusCode || 500;
const isProd = process.env.NODE_ENV === 'production';
const safe = error.isOperational === true;
const body = {
success: false,
error: {
message: safe || !isProd ? error.message : 'Something went wrong',
code: error.code || 'SERVER_ERROR',
statusCode
}
};
if (error.details) body.error.details = error.details;
if (!isProd) body.error.stack = error.stack;
// Log server-side; never swallow 500s silently
if (statusCode >= 500) console.error(err);
res.status(statusCode).json(body);
};
module.exports = { notFoundHandler, errorHandler };
β Verify M1
Boot the server and hit any nonsense URL. You should get 404 with a JSON body shaped like { "success": false, "error": { "code": "ROUTE_NOT_FOUND", ... } }. If you do, your error pipeline works before you've written a single feature.
Milestone 2 β Auth
Now the first real vertical slice: registration and login. Passwords are hashed with bcrypt before saving, and a successful login hands back a signed JWT the client stores and resends.
// src/models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
name: { type: String, required: [true, 'Name is required'], trim: true },
email: {
type: String, required: [true, 'Email is required'],
unique: true, lowercase: true, trim: true
},
password: {
type: String, required: [true, 'Password is required'],
minlength: 8, select: false // never returned by default
},
role: { type: String, enum: ['user', 'admin'], default: 'user' }
}, { timestamps: true });
// Hash only when the password actually changed
userSchema.pre('save', async function () {
if (!this.isModified('password')) return;
this.password = await bcrypt.hash(this.password, 10);
});
userSchema.methods.matchPassword = function (plain) {
return bcrypt.compare(plain, this.password);
};
module.exports = mongoose.model('User', userSchema);
// src/utils/jwt.js
const jwt = require('jsonwebtoken');
exports.sign = (user) =>
jwt.sign({ id: user._id, role: user.role }, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRES_IN || '1d'
});
// src/services/authService.js
const User = require('../models/User');
const { ConflictError, AuthenticationError } = require('../errors/errorTypes');
exports.register = async ({ name, email, password }) => {
if (await User.findOne({ email })) {
throw new ConflictError('An account with that email already exists');
}
return User.create({ name, email, password });
};
exports.login = async ({ email, password }) => {
const user = await User.findOne({ email }).select('+password');
if (!user || !(await user.matchPassword(password))) {
throw new AuthenticationError('Invalid credentials'); // same message for both
}
return user;
};
// src/controllers/authController.js
const asyncHandler = require('../errors/asyncHandler');
const authService = require('../services/authService');
const { sign } = require('../utils/jwt');
exports.register = asyncHandler(async (req, res) => {
const user = await authService.register(req.body);
res.status(201).json({ success: true, token: sign(user) });
});
exports.login = asyncHandler(async (req, res) => {
const user = await authService.login(req.body);
res.status(200).json({ success: true, token: sign(user) });
});
exports.me = asyncHandler(async (req, res) => {
res.status(200).json({ success: true, data: req.user });
});
// src/middleware/auth.js
const jwt = require('jsonwebtoken');
const asyncHandler = require('../errors/asyncHandler');
const { AuthenticationError, AuthorizationError } = require('../errors/errorTypes');
const User = require('../models/User');
exports.protect = asyncHandler(async (req, res, next) => {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.split(' ')[1] : null;
if (!token) throw new AuthenticationError('No token provided');
const decoded = jwt.verify(token, process.env.JWT_SECRET); // throws -> caught centrally
const user = await User.findById(decoded.id);
if (!user) throw new AuthenticationError('User no longer exists');
req.user = user;
next();
});
exports.authorize = (...roles) => (req, res, next) => {
if (!roles.includes(req.user.role)) {
throw new AuthorizationError(`Role '${req.user.role}' cannot access this resource`);
}
next();
};
// src/routes/authRoutes.js
const router = require('express').Router();
const ctrl = require('../controllers/authController');
const { protect } = require('../middleware/auth');
const validate = require('../middleware/validate'); // added in M4
const { registerRules, loginRules } = require('../utils/validation');
router.post('/register', validate(registerRules), ctrl.register);
router.post('/login', validate(loginRules), ctrl.login);
router.get('/me', protect, ctrl.me);
module.exports = router;
β οΈ Same error for "no user" and "wrong password"
Notice login() throws the identical "Invalid credentials" whether the email doesn't exist or the password is wrong. Different messages let an attacker enumerate which emails are registered. This is a small line with a real security payoff.
β Verify M2
Register a user, log in, copy the token, and call GET /api/auth/me with an Authorization: Bearer <token> header. Without the header you should get a 401; with it, your profile.
Milestone 3 β Books CRUD
This is the heart of the app, and it's where the ownership rule lives. Every book stores the user who created it; the service enforces that only that user can read or change it.
// src/models/Book.js
const mongoose = require('mongoose');
const bookSchema = new mongoose.Schema({
title: { type: String, required: [true, 'Title is required'], trim: true, index: true },
author: { type: String, required: [true, 'Author is required'], trim: true, index: true },
genre: { type: String, trim: true },
publishedYear: Number,
status: { type: String, enum: ['to-read', 'reading', 'finished'], default: 'to-read' },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true }
}, { timestamps: true });
module.exports = mongoose.model('Book', bookSchema);
// src/services/bookService.js
const Book = require('../models/Book');
const { NotFoundError, AuthorizationError } = require('../errors/errorTypes');
// Fetch a book and confirm it belongs to this user β the reusable guard
async function findOwned(id, userId) {
const book = await Book.findById(id);
if (!book) throw new NotFoundError('Book', id);
if (book.user.toString() !== userId.toString()) {
throw new AuthorizationError('This book belongs to another user');
}
return book;
}
exports.list = async (userId, { page = 1, limit = 10, search }) => {
const query = { user: userId };
if (search) {
query.$or = [
{ title: { $regex: search, $options: 'i' } },
{ author: { $regex: search, $options: 'i' } }
];
}
const skip = (page - 1) * limit;
const [books, total] = await Promise.all([
Book.find(query).sort({ createdAt: -1 }).skip(skip).limit(limit),
Book.countDocuments(query)
]);
return { books, total, page, totalPages: Math.ceil(total / limit) };
};
exports.get = (id, userId) => findOwned(id, userId);
exports.create = (data, userId) => Book.create({ ...data, user: userId });
exports.update = async (id, data, userId) => {
const book = await findOwned(id, userId);
Object.assign(book, data);
return book.save(); // re-runs schema validators
};
exports.remove = async (id, userId) => {
const book = await findOwned(id, userId);
await book.deleteOne(); // .remove() was removed in Mongoose 7+
};
// src/controllers/bookController.js
const asyncHandler = require('../errors/asyncHandler');
const bookService = require('../services/bookService');
exports.list = asyncHandler(async (req, res) => {
const result = await bookService.list(req.user.id, req.query);
res.status(200).json({
success: true,
count: result.books.length,
pagination: { page: result.page, totalPages: result.totalPages, total: result.total },
data: result.books
});
});
exports.get = asyncHandler(async (req, res) => {
res.status(200).json({ success: true, data: await bookService.get(req.params.id, req.user.id) });
});
exports.create = asyncHandler(async (req, res) => {
const book = await bookService.create(req.body, req.user.id);
res.status(201).json({ success: true, data: book });
});
exports.update = asyncHandler(async (req, res) => {
const book = await bookService.update(req.params.id, req.body, req.user.id);
res.status(200).json({ success: true, data: book });
});
exports.remove = asyncHandler(async (req, res) => {
await bookService.remove(req.params.id, req.user.id);
res.status(200).json({ success: true, data: {} });
});
// src/routes/bookRoutes.js
const router = require('express').Router();
const ctrl = require('../controllers/bookController');
const { protect } = require('../middleware/auth');
const validate = require('../middleware/validate');
const { createBookRules, updateBookRules } = require('../utils/validation');
router.use(protect); // everything below requires a valid token
router.route('/')
.get(ctrl.list)
.post(validate(createBookRules), ctrl.create);
router.route('/:id')
.get(ctrl.get)
.put(validate(updateBookRules), ctrl.update)
.delete(ctrl.remove);
module.exports = router;
π‘ One guard, reused everywhere
The findOwned helper is doing a lot of quiet work: it collapses "not found" and "not yours" into a single, well-tested function that get, update, and remove all lean on. When authorization logic lives in one place, it's far harder to accidentally leave a hole.
β Verify M3
With two different accounts, create a book as user A, then try to GET /api/books/:id for that book while logged in as user B. You must get a 403, not the book.
Milestone 4 β Validation & Polish
Your endpoints work; now make them refuse bad input politely. express-validator defines rules, and one small middleware turns any failures into your standard ValidationError.
// src/middleware/validate.js
const { validationResult } = require('express-validator');
const { ValidationError } = require('../errors/errorTypes');
// Run an array of validators, then bail with a structured 400 if any failed
const validate = (rules) => async (req, res, next) => {
await Promise.all(rules.map((rule) => rule.run(req)));
const result = validationResult(req);
if (result.isEmpty()) return next();
const details = {};
for (const e of result.array()) details[e.path] = e.msg;
next(new ValidationError('Validation failed', details));
};
module.exports = validate;
// src/utils/validation.js
const { body } = require('express-validator');
exports.registerRules = [
body('name').trim().notEmpty().withMessage('Name is required'),
body('email').trim().isEmail().withMessage('A valid email is required').normalizeEmail(),
body('password')
.isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
.matches(/\d/).withMessage('Password must contain a number')
.matches(/[A-Z]/).withMessage('Password must contain an uppercase letter')
];
exports.loginRules = [
body('email').trim().isEmail().withMessage('A valid email is required'),
body('password').notEmpty().withMessage('Password is required')
];
exports.createBookRules = [
body('title').trim().notEmpty().withMessage('Title is required'),
body('author').trim().notEmpty().withMessage('Author is required'),
body('publishedYear').optional()
.isInt({ min: 1000, max: new Date().getFullYear() })
.withMessage('Year looks out of range')
];
// For PUT, fields are optional but still validated when present
exports.updateBookRules = [
body('title').optional().trim().notEmpty().withMessage('Title cannot be empty'),
body('author').optional().trim().notEmpty().withMessage('Author cannot be empty')
];
A failed POST /api/books now returns:
{
"success": false,
"error": {
"message": "Validation failed",
"code": "VALIDATION_ERROR",
"statusCode": 400,
"details": { "title": "Title is required" }
}
}
A quick integration test
Because app.js doesn't call listen(), Supertest can drive it directly against an in-memory Mongo:
// tests/books.test.js
const request = require('supertest');
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const createApp = require('../src/app');
const app = createApp();
let mongo, token;
beforeAll(async () => {
mongo = await MongoMemoryServer.create();
await mongoose.connect(mongo.getUri());
const res = await request(app).post('/api/auth/register')
.send({ name: 'Ada', email: 'ada@example.com', password: 'Password1' });
token = res.body.token;
});
afterAll(async () => {
await mongoose.disconnect();
await mongo.stop();
});
test('creates a book for the logged-in user', async () => {
const res = await request(app).post('/api/books')
.set('Authorization', `Bearer ${token}`)
.send({ title: 'Clean Code', author: 'Robert C. Martin' });
expect(res.statusCode).toBe(201);
expect(res.body.data.title).toBe('Clean Code');
});
test('rejects a book with no title', async () => {
const res = await request(app).post('/api/books')
.set('Authorization', `Bearer ${token}`)
.send({ author: 'Nobody' });
expect(res.statusCode).toBe(400);
expect(res.body.error.code).toBe('VALIDATION_ERROR');
});
π‘ Stretch goals (only after the core is green)
- Collections β group books, reusing the same ownership pattern.
- Reviews & ratings β a second owned resource that references a book.
- Rate limiting β
express-rate-limiton/api/auth/loginto slow brute-force attempts. - Swagger docs β an
/api-docspage from an OpenAPI spec.
Definition of Done Checklist
Before you call the project finished, walk this list top to bottom. If every box is honestly checked, you've built something real.
π Core β must pass
- β Server starts cleanly and connects to the database.
- β An unknown route returns a
404in your standard JSON error shape. - β Register β login β receive a JWT; passwords are hashed (never stored or returned in plaintext).
- β Protected routes reject requests with no/invalid/expired token (
401). - β Full Books CRUD works for the owner.
- β A user cannot read or modify another user's book (
403). - β Invalid input returns a
400with adetailsobject. - β All errors flow through the single central error handler β no
try/catchscattered in controllers. - β
.envis git-ignored;JWT_SECRETis not hard-coded.
β Bonus β nice to have
- β At least a couple of passing integration tests.
- β Pagination and search on the books list.
- β A README with setup steps and sample requests.
- β One stretch feature implemented.
What Good Looks Like
Two people can both "finish" this project and hand in very different work. Here's how to tell a solid submission from a shaky one.
| Aspect | π’ Good | π΄ Needs work |
|---|---|---|
| Error handling | Every failure path throws an AppError; one handler renders them. | try/catch in every controller, each returning a slightly different shape. |
| Controllers | Thin β read request, call service, respond. | Fat β database queries and business rules mixed into the route handler. |
| Authorization | Ownership checked in one reusable helper. | Ownership check copy-pasted, and missing on delete. |
| Secrets | In .env, git-ignored. | JWT secret committed in the source. |
| Responses | Consistent { success, data } / { success, error }. | Bare arrays here, strings there, HTML error pages elsewhere. |
| Scope | Core done and tested; one stretch feature. | Six half-built features, none fully working. |
π‘ The real test: ask a classmate to add a "favorite" flag to a book. If they can do it by touching only the model, validator, and maybe a service line β without fear of breaking auth or error handling β your architecture did its job.
Quiz
π― Quick Check
Question 1: Why is the Express app exported from app.js without calling listen()?
Question 2: Login throws the same "Invalid credentials" message whether the email is unknown or the password is wrong. Why?
Question 3: Where does the "a user may only touch their own books" rule belong?
Summary & What's Next
π Key Takeaways
- Build in vertical slices and milestones β always keep a running app.
- Layered responsibilities (routes β controllers β services β models) make features cheap to add.
- Put the error core in first; then every feature can just
throw. - Enforce ownership in one reusable guard, and don't leak whether an email exists.
- Judge your work against an explicit definition of done, not a vague feeling of "it runs."
π Further Reading
- Express β Error Handling Guide
- Node.js Best Practices (goldbergyoni)
- Mongoose β Schemas & Guide
- OWASP β REST Security Cheat Sheet
π What's Next?
You've now built a complete backend in JavaScript. Next module we switch languages entirely β same fundamentals, new dialect β starting with an overview of Python's Flask framework, so you can compare how another ecosystem solves the exact problems you just solved here.
π Module complete!
Ship the Bookshelf API, run it against the checklist, and push it to GitHub. That's a portfolio piece.