Everything we have stored so far lived in arrays that vanish when the server restarts. Time to fix that. MongoDB is a document database that stores JSON-like records, making it a natural partner for Express. We will connect through Mongoose, the most popular MongoDB library for Node, define a schema, and rewrite our to-do endpoints to persist data.
MongoDB stores documents that look like JavaScript objects, so there is no translation layer between your API payloads and your database rows. Mongoose adds two things raw drivers lack: schemas that validate documents before they are saved, and a model API with friendly methods like find, create, and findByIdAndUpdate.
You have two easy options. Locally, install MongoDB Community Server and it listens on mongodb://127.0.0.1:27017. In the cloud, MongoDB Atlas has a free tier: create a cluster, add a database user, allow your IP, and copy the connection string. Either way, keep the string in an environment variable, never in code:
npm install mongoose dotenvCreate a .env file (and add it to .gitignore):
MONGODB_URI=mongodb://127.0.0.1:27017/express_tutorial
require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
const app = express();
app.use(express.json());
async function start() {
await mongoose.connect(process.env.MONGODB_URI);
console.log('MongoDB connected');
app.listen(3000, () => console.log('API on port 3000'));
}
start().catch(err => {
console.error('Failed to start:', err.message);
process.exit(1);
});Connecting before listening means the app never accepts traffic it cannot serve. If the database is down, the process exits loudly instead of failing on every request.
// models/Todo.js
const mongoose = require('mongoose');
const todoSchema = new mongoose.Schema({
title: { type: String, required: true, trim: true, maxlength: 200 },
done: { type: Boolean, default: false },
}, { timestamps: true });
module.exports = mongoose.model('Todo', todoSchema);The schema enforces rules at the data layer: title is mandatory, whitespace is trimmed, and timestamps adds createdAt and updatedAt automatically. The model Todo maps to a todos collection.
const Todo = require('./models/Todo');
app.get('/api/todos', async (req, res) => {
const todos = await Todo.find().sort({ createdAt: -1 });
res.json(todos);
});
app.post('/api/todos', async (req, res) => {
const todo = await Todo.create({ title: req.body.title });
res.status(201).json(todo);
});
app.patch('/api/todos/:id', async (req, res) => {
const todo = await Todo.findByIdAndUpdate(
req.params.id,
{ done: req.body.done },
{ new: true, runValidators: true }
);
if (!todo) return res.status(404).json({ error: 'Not found' });
res.json(todo);
});Every model method returns a promise, so handlers become async functions. The new: true option makes updates return the modified document rather than the original.
If a client posts an empty title, Todo.create rejects with a ValidationError. In Express 5, errors thrown in async handlers are forwarded to the error-handling middleware automatically, which we will build properly in lesson 12. Malformed IDs raise a CastError, which you should translate to a 400 response.
Next lesson: Building a REST API with Express, where we assemble these pieces into a complete, well-designed API.