Lesson 20 of 20

Final Project: Build a REST API

Project Setup and Configuration

This last lesson puts the whole course into one project: a Task Manager API where people register, log in, and manage tasks that belong only to them. It is deliberately small in scope and complete in structure — every piece has appeared in an earlier lesson, and the value is in watching them fit together into something you could deploy.

Write down what it does before writing any code. Anybody can register with a name, an email and a password. Anybody registered can log in and receive a token. A logged-in user can create, list, filter, update and delete their own tasks, and cannot see anybody else's. That is six endpoints and two models, and having it written down is what stops a project growing sideways while you build it.

The dependency list is short and every entry earns its place: express for routing, mongoose for the database, bcrypt for password hashing, jsonwebtoken for tokens, dotenv for configuration in development, and helmet plus cors for the perimeter. Nothing else is required to have something you can genuinely show somebody.

server.js loads configuration, connects to the database, and only then starts listening. src/app.js builds the Express application and exports it without ever calling listen. That separation is what will let you test the API in memory later, and it is why a failed connection exits the process rather than leaving a server up that cannot answer anything it is asked.

Set the environment variables before writing a single route: PORT, NODE_ENV, DATABASE_URL, JWT_SECRET, CORS_ORIGIN. Generate the secret rather than inventing one. Add .env to .gitignore and commit .env.example in the same first commit — doing it now costs a minute, and doing it after a push costs you a rotated key and an uncomfortable afternoon.

Example
// Initialize project
// mkdir task-api && cd task-api
// npm init -y
// npm install express mongoose bcrypt jsonwebtoken dotenv helmet cors
// npm install -D nodemon

// .env  — never committed; commit .env.example with the names and no values
// PORT=3000
// NODE_ENV=development
// DATABASE_URL=mongodb://localhost:27017/taskmanager
// CORS_ORIGIN=http://localhost:5173
// JWT_SECRET=      generate it, do not invent it:
//   node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"

// server.js — Entry point
require('dotenv').config();
const mongoose = require('mongoose');
const app = require('./src/app');

const PORT = process.env.PORT || 3000;

mongoose.connect(process.env.DATABASE_URL)
  .then(() => {
    console.log('Connected to MongoDB');
    app.listen(PORT, () => {
      console.log(`Task API running on http://localhost:${PORT}`);
    });
  })
  .catch(err => {
    console.error('Database connection failed:', err.message);
    process.exit(1);
  });

// src/app.js — Express app setup
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const authRoutes = require('./routes/authRoutes');
const taskRoutes = require('./routes/taskRoutes');
const errorHandler = require('./middleware/errorHandler');

const app = express();

app.use(helmet());
app.use(cors({ origin: process.env.CORS_ORIGIN }));   // not bare cors() — lesson 10
app.use(express.json({ limit: '100kb' }));

app.use('/api/auth', authRoutes);
app.use('/api/tasks', taskRoutes);

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

app.use(errorHandler);

module.exports = app;
  • express — routing and middleware
  • mongoose — schemas, validation and queries
  • bcrypt — password hashing
  • jsonwebtoken — signing and verifying tokens
  • dotenv — configuration from .env in development only
  • helmet + cors — response headers and one specific allowed origin
  • server.js connects and listens; src/app.js builds and exports the app
Notes
  • Connect to the database before calling listen, and exit if that connection fails. A server that accepts requests while its database is unreachable answers the first arrivals with confusing 500 errors and looks, to any monitoring, perfectly healthy. Failing at startup is loud, immediate, and far easier to fix.

Models, Auth Routes, and Task Routes

Two models carry the whole application. User holds a name, an email that is unique and lowercased, and a password field storing a bcrypt hash and declared select: false so it cannot leave the server by accident. Task holds a title, a description, a completed flag, a priority from a fixed set, and an owner pointing at a user.

The hash is produced by a pre('save') hook, guarded with isModified('password') so that saving a user for some other reason does not re-hash an already hashed value. Know its limit: the hook runs on save and on create, and it does not run for findOneAndUpdate. A password-change route written with findByIdAndUpdate stores whatever the user typed, in plain text, without any error at all.

The auth routes are short. Registration creates the user and returns a token. Login finds the account, compares the password with bcrypt.compare, and returns a token — with the same message and the same status code for a wrong email as for a wrong password. Notice the String() conversions on the login input: without them, a JSON body containing an operator instead of a string is the login bypass from lesson 14.

router.use(auth) at the top of the task router protects every route in the file, including the one you add next month and would otherwise forget. Then the rule that matters most in the entire project: owner: req.user.id goes into the filter of every query, never into an if afterwards. Somebody else's task is then simply not found, which is both simpler code and better behaviour than a 403.

Notice also that nothing spreads req.body. Every field is named explicitly, because { ...req.body, owner: req.user.id } would let a client send its own _id, and the same spread in the update route would let one send owner and give their task away to somebody else. Naming the fields costs two lines and closes an entire category of problem.

Example
// src/models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');

const userSchema = new mongoose.Schema({
  name: { type: String, required: true, trim: true },
  email: { type: String, required: true, unique: true, lowercase: true },
  // select: false -> never returned by a query unless explicitly asked for
  password: { type: String, required: true, minlength: 8, select: false }
});

// Runs on save() and create(). Does NOT run on findOneAndUpdate().
userSchema.pre('save', async function(next) {
  if (this.isModified('password')) {
    this.password = await bcrypt.hash(this.password, 12);
  }
  next();
});

module.exports = mongoose.model('User', userSchema);

// src/models/Task.js
const mongoose = require('mongoose');

const taskSchema = new mongoose.Schema({
  title: { type: String, required: true, trim: true },
  description: { type: String, default: '' },
  completed: { type: Boolean, default: false },
  priority: { type: String, enum: ['low', 'medium', 'high'], default: 'medium' },
  owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }
}, { timestamps: true });

module.exports = mongoose.model('Task', taskSchema);

// src/routes/authRoutes.js
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const User = require('../models/User');
const router = express.Router();

router.post('/register', async (req, res, next) => {
  try {
    const { name, email, password } = req.body;
    const user = await User.create({ name, email, password });
    const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, { expiresIn: '24h' });
    res.status(201).json({ token, user: { id: user._id, name, email } });
  } catch (err) {
    if (err.code === 11000) return res.status(400).json({ error: 'Email already registered' });
    next(err);
  }
});

router.post('/login', async (req, res, next) => {
  try {
    // String() matters: {"email":{"$ne":null}} would otherwise be an operator
    const email = String(req.body.email || '').toLowerCase().trim();
    const password = String(req.body.password || '');

    const user = await User.findOne({ email }).select('+password');
    if (!user || !(await bcrypt.compare(password, user.password))) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }
    const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, { expiresIn: '24h' });
    res.json({ token, user: { id: user._id, name: user.name, email } });
  } catch (err) { next(err); }
});

module.exports = router;

// src/routes/taskRoutes.js
const express = require('express');
const Task = require('../models/Task');
const auth = require('../middleware/auth');
const router = express.Router();

router.use(auth); // All task routes require authentication

router.get('/', async (req, res, next) => {
  try {
    const { completed, priority, sort = '-createdAt' } = req.query;
    const filter = { owner: req.user.id };
    if (completed !== undefined) filter.completed = completed === 'true';
    if (priority) filter.priority = priority;
    const tasks = await Task.find(filter).sort(sort);
    res.json(tasks);
  } catch (err) { next(err); }
});

router.post('/', async (req, res, next) => {
  try {
    // Name the fields. { ...req.body } would let a client set _id or owner.
    const task = await Task.create({
      title: req.body.title,
      description: req.body.description,
      priority: req.body.priority,
      owner: req.user.id
    });
    res.status(201).json(task);
  } catch (err) { next(err); }
});

router.put('/:id', async (req, res, next) => {
  try {
    // owner is in the FILTER, so another user's task is simply not found
    const task = await Task.findOneAndUpdate(
      { _id: req.params.id, owner: req.user.id },
      {
        title: req.body.title,
        description: req.body.description,
        completed: req.body.completed,
        priority: req.body.priority
      },
      { new: true, runValidators: true }   // both options matter — lesson 14
    );
    if (!task) return res.status(404).json({ error: 'Task not found' });
    res.json(task);
  } catch (err) { next(err); }
});

router.delete('/:id', async (req, res, next) => {
  try {
    const task = await Task.findOneAndDelete({ _id: req.params.id, owner: req.user.id });
    if (!task) return res.status(404).json({ error: 'Task not found' });
    res.status(204).send();
  } catch (err) { next(err); }
});

module.exports = router;
  • User — name, unique lowercased email, hashed password with select: false
  • Task — title, description, completed, a priority enum, and an owner reference
  • pre('save') hashes the password — and does not run for findOneAndUpdate
  • router.use(auth) protects every route in the file, including future ones
  • owner: req.user.id belongs in the query filter, never in a later if
  • Name every field you accept; never spread req.body into a document
  • Identical message and status for a wrong email as for a wrong password
Notes
  • The pre('save') limitation is the trap hiding in this project. Add a "change password" route using findByIdAndUpdate and the hook never fires, so the new password is stored exactly as the user typed it. Nothing throws and nothing looks wrong from outside — the user simply cannot log in afterwards, and the reason is a plain-text password now sitting in your database. Load the document, assign the field, and call save().

Auth Middleware and Error Handler

The auth middleware is the smallest important file in the project. It reads the Authorization header, checks that it begins with Bearer, verifies the token, and attaches the payload to req.user. Everything that fails is a 401. Because it is one file, there is exactly one place to look when authentication misbehaves and exactly one place to change when it evolves.

Use jwt.verify and never jwt.decode. verify checks the signature and the expiry; decode reads the payload and believes whatever it finds there, which means anybody can hand-write a token claiming to be an administrator. That single function name is the difference between authentication and a polite suggestion.

The error handler is the other single-purpose file. It recognises Mongoose's ValidationError and turns it into a 400 listing the offending fields, treats a CastError as a malformed id, treats a duplicate key as a conflict, and turns everything it does not recognise into a 500 with the detail logged rather than sent. Four parameters, registered after the routes and after the 404 handler.

Notice what never reaches the client: the stack trace, the driver's message, the names of your database fields. Those belong in the log. In production, the response to an unexpected failure should be a generic sentence and, ideally, a request id — so that somebody reporting a problem hands you something you can search for instead of a description of what they were doing at the time.

That is the whole application. Six endpoints, two models, three middlewares and a config module — and every single piece of it came from an earlier lesson in this course. If you have followed along, you have a backend that would not embarrass you in a code review. The remaining two sections are about proving that it works and being able to talk about it.

Example
// src/middleware/auth.js
const jwt = require('jsonwebtoken');

function authenticate(req, res, next) {
  const header = req.headers.authorization;
  if (!header || !header.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Authentication required' });
  }
  try {
    const token = header.split(' ')[1];
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch (err) {
    res.status(401).json({ error: 'Invalid or expired token' });
  }
}

module.exports = authenticate;

// src/middleware/errorHandler.js
function errorHandler(err, req, res, next) {
  console.error(`[${new Date().toISOString()}] ${err.stack}`);

  if (err.name === 'ValidationError') {
    const messages = Object.values(err.errors).map(e => e.message);
    return res.status(400).json({ error: 'Validation failed', details: messages });
  }
  if (err.name === 'CastError') {
    return res.status(400).json({ error: 'Invalid ID format' });
  }
  if (err.code === 11000) {
    return res.status(400).json({ error: 'Duplicate value' });
  }

  res.status(err.statusCode || 500).json({
    error: err.message || 'Internal Server Error'
  });
}

module.exports = errorHandler;

// --- Testing your API with curl ---
// Register:
// curl -X POST http://localhost:3000/api/auth/register \
//   -H "Content-Type: application/json" \
//   -d '{"name":"Alice","email":"alice@test.com","password":"pass123"}'
//
// Login:
// curl -X POST http://localhost:3000/api/auth/login \
//   -H "Content-Type: application/json" \
//   -d '{"email":"alice@test.com","password":"pass123"}'
//
// Create task (use token from login):
// curl -X POST http://localhost:3000/api/tasks \
//   -H "Content-Type: application/json" \
//   -H "Authorization: Bearer YOUR_TOKEN" \
//   -d '{"title":"Learn Node.js","priority":"high"}'
  • One middleware verifies the token and sets req.user; everything else is a 401
  • jwt.verify, never jwt.decode — decode checks nothing at all
  • One error handler, four arguments, registered after the routes and the 404
  • ValidationError → 400 · CastError → 400 · duplicate key → 409
  • Stack traces go to the log; the client gets a sentence and a request id
  • Six endpoints, two models, three middlewares — all of it from earlier lessons
Notes
  • If authentication starts failing everywhere at once, check three things in this order: is JWT_SECRET the same value that signed the token, has the token expired, and is the header exactly Authorization: Bearer <token> with a single space. Those three account for very nearly every "invalid token" you will ever see.

Testing the Whole Flow

Before calling this finished, walk the API through the sequence a real client would follow, in order, checking the status code at every step. It takes ten minutes and it is the difference between a project that works and a project you believe works.

The happy path first. Register and expect 201 with a token. Log in with the same credentials and expect 200 with a token. Create a task using that token and expect 201. List the tasks and expect to see only your own. If any of those returns the wrong code — a 200 where 201 belongs, for instance — that is a bug, even though the data is correct.

Then the security cases, which are the ones that matter. Request a task with no token and expect 401. Register a second user, log in as them, and request the first user's task id directly: expect 404, not the task. That single check is the one most student projects fail, it takes thirty seconds, and it is exactly what somebody reviewing your code will try first.

Then the input cases. Post a task with no title and expect 400 with a field-level message rather than a 500. Request /api/tasks/abc and expect 400, because a malformed id is a client error. Register with an email that already exists and expect a clear 409 or 400, not a stack trace mentioning a duplicate key. Attempt to log in with a password of {"$ne": null} and expect 401.

Then save the requests. Whether as a Postman collection or a file of curl commands committed to the repository, they document the API and become the first tests you automate with supertest — which, because app is exported without ever calling listen, needs no running server at all.

Example
# 1. Register  -> expect 201 and a token
curl -i -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"name":"Ananya","email":"ananya@example.com","password":"correct-horse-9"}'

# 2. Log in    -> expect 200 and a token
TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"ananya@example.com","password":"correct-horse-9"}' | jq -r .token)

# 3. Create a task -> expect 201
curl -i -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"title":"Finish the Node course","priority":"high"}'

# 4. List your tasks -> expect 200 and only YOUR tasks
curl -s http://localhost:3000/api/tasks -H "Authorization: Bearer $TOKEN"

# --- now the cases that actually matter ---

# No token                  -> 401
curl -i http://localhost:3000/api/tasks

# Another user's task id    -> 404 (not the task, and not 403)
curl -i http://localhost:3000/api/tasks/PASTE_OTHER_USERS_TASK_ID \
  -H "Authorization: Bearer $TOKEN"

# Missing title             -> 400 with a field-level message
curl -i -X POST http://localhost:3000/api/tasks \
  -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d '{}'

# Malformed id              -> 400, not 500
curl -i http://localhost:3000/api/tasks/abc -H "Authorization: Bearer $TOKEN"

# NoSQL injection attempt   -> 401
curl -i -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"ananya@example.com","password":{"$ne":null}}'
  • Register → 201 · log in → 200 · create → 201 · list → only your own tasks
  • No token → 401 · another user's id → 404 · missing field → 400 · bad id → 400
  • A login body of {"$ne": null} must produce 401, not a session
  • A duplicate email must produce a clear 409 or 400, never a stack trace
  • Check the status codes, not only the bodies — a 200 where 201 belongs is a bug
  • Save the requests; they become your first supertest tests
Notes
  • Repeat the cross-user check every single time you add a route. Create something as user A, then request it as user B. If it comes back, the ownership filter is missing from that query — and the route that gets forgotten is almost always the newest one, added in a hurry after all the earlier ones were tested and forgotten about.

Shipping It, and Talking About It

Deployment is where the configuration lessons get cashed in. Set every environment variable on the platform, not merely in your local .env, which is not deployed at all. Read process.env.PORT rather than hardcoding one. Set NODE_ENV=production. Point CORS_ORIGIN at your real frontend instead of localhost. Use a hosted database rather than one running on your laptop. Install with npm ci.

Then verify the unglamorous things. Is .env genuinely absent from the repository — check the history, not just the working folder, because git remembers. Does the health endpoint actually check the database. Does an unexpected error return a generic message rather than a stack trace. Does the process shut down cleanly on SIGTERM. Each is a minute of work, and each is something a reviewer notices immediately.

Write a README that lets a stranger run this in five minutes: what it is in one sentence, the endpoint list with methods and whether each needs authentication, how to install, which environment variables to set, and how to start it. If you deployed it, put the live URL at the top. This is the first thing anybody opens, including whoever is deciding whether to interview you.

When you talk about the project, do not recite the technology list — everybody's list is identical, and it says nothing about you. Talk about a decision instead. Why tokens rather than sessions, and what you gave up by choosing them. Why the owner is in the query rather than in a check afterwards. Why the config module throws at startup instead of defaulting. One decision explained properly is worth five features named.

Sensible extensions, roughly in order of value: automated tests with supertest, pagination and filtering on the task list, a refresh-token flow so access tokens can be short-lived, file uploads with proper validation, and a small frontend that consumes the API. Each of those is one lesson from this course applied once more, which is how the material actually settles into something you can use without looking anything up.

Example
# Install exactly what the lock file says
npm ci

# Variables the PLATFORM must have — not just your local .env
#   PORT           usually set for you: read it, never hardcode 3000
#   NODE_ENV=production
#   DATABASE_URL   a hosted database, not localhost
#   JWT_SECRET     generated, and different from your development one
#   CORS_ORIGIN    your real frontend URL

# Confirm .env never entered the repository (history, not just the folder)
git log --all --oneline -- .env      # this must print nothing at all

# package.json
{
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js",
    "test": "node --test"
  }
}

# README.md — the five things a stranger needs
#   1. What this is, in one sentence
#   2. The endpoint table: method, path, what it does, whether auth is required
#   3. npm install, then which .env variables to set
#   4. npm run dev
#   5. The live URL, if you deployed it
  • Every variable set on the platform, not only in your local .env
  • process.env.PORT, NODE_ENV=production, and a real CORS_ORIGIN
  • npm ci for installs; a hosted database, never your laptop
  • Confirm .env never entered git history, not merely that it is gone now
  • A README with the endpoint table, the variables, and the commands to run
  • In an interview, explain one decision rather than listing six technologies
Notes
  • You have built a complete backend: routing, middleware, validation, a database, hashed passwords, tokens, ownership checks, configuration and error handling. That is genuinely the shape of a production service — the difference between this and a real one is mostly scale and operational work rather than a different kind of code. Deploy it, put the link in your README, and build the next one faster.
Ask AI