Almost every modern web application will need a REST API for a client to talk to, and in almost every scenario, that client is going to expect JSON. The best developer experience is a stack where you can stay in JSON-shaped data end to end, without awkward transformations in the middle.
Take MongoDB, Express Framework, and Node.js as an example.
Express receives HTTP requests and sends responses. MongoDB sits in the middle and stores documents. The client can send JSON to your routes, your routes can send documents to MongoDB, and MongoDB can hand BSON back that maps naturally to what you serialize in the response. That works well because MongoDB is a document database. When you also want text search over fields like title and plot, MongoDB Search gives you a $search stage in an aggregation pipeline on the same cluster, so you are not bolting on a separate search system just to power a search box.
In this tutorial, we'll see how to build a small movie watchlist API using TypeScript and MongoDB. We'll explore a few different schema design opportunities and make use of MongoDB Search for full-text search.
The Prerequisites
Prior to starting this tutorial, you'll need a few things in place:
- A MongoDB Atlas instance (M0 is fine) with the
sample_mflixsample dataset loaded and available. - Node.js 22+
The expectation is that your cluster is already provisioned with credentials and network access rules, and that the sample dataset is loaded. If you need help deploying or configuring MongoDB Atlas, check out the MongoDB documentation for getting started.
We'll be working with an empty project, so to kick things off, you might want to run the following commands:
mkdir movie-watchlist-api
cd movie-watchlist-api
npm init -y
The above commands will create a new project directory and initialize it for Node.js by creating a package.json file.
There are a few dependencies that we need to install. They can be installed by executing the following commands:
npm install express mongodb dotenv
In the above, we're installing Express Framework and the MongoDB Node.js Driver, plus a package that will allow us to read from a .env file for configuration variables.
For TypeScript, the tsx runner (useful in development with watch mode), and the Express and Node type packages, run:
npm install --save-dev typescript tsx @types/express @types/node
To run this project in development mode, you'd execute tsx watch src/app.ts from your command line. However, that won't be possible until later in the tutorial.
Add a TypeScript Configuration File
Because we're using TypeScript, we need a tsconfig.json at the root of the project. Create the file with something along the following lines:
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"]
}
The goal here is to emit CommonJS into dist/ from everything under src/, which keeps production as simple as node dist/app.js after tsc.
Understanding the Source Tree of the Project
With the tooling in place, it helps to know where things will live. This project will use the following foundation:
- src/config.ts
- src/db.ts
- src/types/watchlist.ts
- src/routes/movies.ts
- src/routes/watchlist.ts
- src/app.ts
Each file will serve a different purpose that we'll explore throughout the remainder of the tutorial. Go ahead and create the src, src/routes, and src/types directories so you're ready to paste code as we go.
Define and Configure the Environment Variables for the Project
We're going to need connection details before the Express Framework application can talk to MongoDB Atlas. Add the following to a .env file at the project's root:
MONGODB_URI=mongodb+srv://<USERNAME>:<PASSWORD>@<HOST>/
PORT=3000
SEARCH_INDEX_NAME=default
Make sure to add this file to your project's .gitignore if you're using version control, since the MONGODB_URI is considered sensitive information.
Rather than read process.env in every route file of the project, we'll define a small configuration module that validates what is required and exports plain values.
Open src/config.ts and include the following code:
import "dotenv/config";
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value?.trim()) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value.trim();
}
export const config = {
mongoUri: requiredEnv("MONGODB_URI"),
port: Number(process.env.PORT) || 3000,
dbName: "sample_mflix",
moviesCollection: "movies",
watchlistCollection: "mflix_watchlist",
searchIndexName: process.env.SEARCH_INDEX_NAME?.trim() || "default",
};
The import "dotenv/config" line means that as soon as something imports config.ts, your .env values are loaded. Database and collection names are hard-coded to sample_mflix, movies, and mflix_watchlist, so this tutorial stays aligned with the sample dataset.
Connecting to MongoDB with a Singleton Database Class
You could open a new MongoClient on every request, but that usually is not what you want. It adds latency, wastes resources, and makes graceful shutdown harder. The pattern we'll use instead is a singleton where we'll connect once when the application boots, reuse that client for every request, and close it when the process exits.
With the environment variables already in config.ts, open src/db.ts and include the following code:
import { MongoClient, type Db } from "mongodb";
import { config } from "./config";
let singleton: Database | null = null;
export class Database {
private constructor(
private readonly mongoClient: MongoClient,
readonly db: Db
) {}
static async connect(): Promise<Database> {
if (singleton) return singleton;
const mongoClient = new MongoClient(config.mongoUri, {
appName: "devrel-tutorial-typescript-moviewatchlist",
});
await mongoClient.connect();
const db = mongoClient.db(config.dbName);
singleton = new Database(mongoClient, db);
await singleton.ensureWatchlistIndexes();
return singleton;
}
static get instance(): Database {
if (!singleton) {
throw new Error("Database not connected");
}
return singleton;
}
async close(): Promise<void> {
await this.mongoClient.close();
singleton = null;
}
private async ensureWatchlistIndexes(): Promise<void> {
const watchlistCollection = this.db.collection(
config.watchlistCollection
);
await watchlistCollection.createIndex({ movieId: 1 }, { unique: true });
}
}
In the connect method, we have the following logic:
static async connect(): Promise<Database> {
if (singleton) return singleton;
const mongoClient = new MongoClient(config.mongoUri, {
appName: "devrel-tutorial-typescript-moviewatchlist",
});
await mongoClient.connect();
const db = mongoClient.db(config.dbName);
singleton = new Database(mongoClient, db);
await singleton.ensureWatchlistIndexes();
return singleton;
}
If singleton is already set, we return immediately and avoid opening another connection. Otherwise we create MongoClient with config.mongoUri and an appName that shows up in MongoDB Atlas, await connect(), take a Db reference for config.dbName (here sample_mflix), assign singleton, and call ensureWatchlistIndexes before returning. Doing the index step before the HTTP server starts listening means every route can assume the watchlist collection already has a unique index on movieId.
The ensureWatchlistIndexes function creates a unique index on movieId in mflix_watchlist, so MongoDB rejects a second insert for the same movie and the watchlist POST handler can reason about "no row", "inactive row", and "active row" without duplicate documents.
Define the Watchlist Document Type for TypeScript
Before we get too far into the routes, it's a good idea to describe the document shape we plan to store in mflix_watchlist. TypeScript will not infer that for you.
In src/types/watchlist.ts, add the following:
import type { ObjectId } from "mongodb";
export interface WatchlistDoc {
_id: ObjectId;
movieId: ObjectId;
title: string;
poster: string | null;
rating?: number;
active: boolean;
createdAt: Date;
updatedAt: Date;
}
We're denormalizing title and poster onto the watchlist document when the user adds or reactivates a movie, so a client can render the list without joining back to movies on every GET. The advantage of this approach is that we're making our queries faster by not doing a $lookup (JOIN) operation unless we absolutely have to. When listing all items in a watchlist, it is probably not common to need details such as the actor cast list, so we're only including the fields we absolutely need alongside the id value in case we want to expand the details later with a single fetch operation.
Searching for Movies within the Catalog with MongoDB Search
To search for movies in our application, it makes sense to leverage MongoDB Search for its full-text search capabilities. Before we can do that, we need to apply a search index on the collection and fields we want to search through.
Take the following MongoDB Search index:
{
"mappings": {
"dynamic": false,
"fields": {
"title": {
"type": "string"
},
"plot": {
"type": "string"
}
}
}
}
In the above index, which is different from the unique index we created earlier, we are saying we only want to be able to use full-text search on the title and plot fields of a document.
To apply the index, navigate to MongoDB Atlas and make use of the Search & Vector Search navigation item. When prompted in the index creation process, make sure to choose sample_mflix as the database and movies as the collection. You can name the index "default" to match what we have in our configuration, or name it whatever makes sense to you. Just make sure to update the environment variables configuration if you change the name.
Open the project's src/routes/movies.ts file and include the following:
import { Router, type IRouter } from "express";
import { config } from "../config";
import { Database } from "../db";
export function moviesRouter(): IRouter {
const router = Router();
router.get("/search", async (req, res, next) => {
try {
const searchQuery = String(req.query.q ?? "").trim();
if (!searchQuery) {
throw new Error(
'Query parameter "q" is required and cannot be empty'
);
}
const pipeline = [
{
$search: {
index: config.searchIndexName,
text: {
query: searchQuery,
path: ["title", "plot"],
},
},
},
{ $limit: 10 },
{
$project: {
_id: 1,
title: 1,
poster: 1,
year: 1,
},
},
];
const database = Database.instance.db;
const moviesCollection = database.collection(
config.moviesCollection
);
const results = await moviesCollection
.aggregate(pipeline)
.toArray();
res.json({ results });
} catch (err) {
next(err);
}
});
return router;
}
The router above has a single GET endpoint that we'll use for searching. It expects a q query parameter to exist in our requests, where this parameter represents a search query.
With a MongoDB aggregation pipeline, we can use $search as our first stage, specifying the index name, the search query, and the fields we wish to search against. For the sake of this example, the second pipeline stage will limit our results to no more than 10 results. Finally, we end our pipeline with a $project stage that allows us to define what fields are actually important to us for returning to the client. In this example, we plan to return the _id, title, poster, and year fields for every document matched.
We are only using MongoDB Search for finding movies we want to add to our watchlist. We need to create a route and a series of endpoints for managing our watchlist next.
Building a Movie Watchlist with Ratings
The watchlist will make up most of our application logic for this tutorial. We want to accomplish the following:
- Add movies based on id to the watchlist, with certain fields, as long as the movie doesn't already exist in the list.
- Add ratings to movies in the watch list.
- Soft-delete (deactivate) movies in the watchlist, but not actually removing the document from the collection.
- Remove ratings from movies in the watchlist.
In the src/routes/watchlist.ts file, let's use the following as a starting point:
import { Router, type IRouter } from "express";
import { ObjectId } from "mongodb";
import { config } from "../config";
import { Database } from "../db";
import type { WatchlistDoc } from "../types/watchlist";
export function watchlistRouter(): IRouter {
const router = Router();
return router;
}
There aren't any endpoints for now, but we're going to add and make sense of each one at a time.
Our most complex endpoint will be the POST endpoint responsible for adding movies to the watchlist. You can find this endpoint below.
router.post("/", async (req, res, next) => {
try {
const database = Database.instance.db;
const movieId = new ObjectId(String(req.body?.movieId));
const watchlistCollection = database.collection(config.watchlistCollection);
const moviesCollection = database.collection(config.moviesCollection);
const existingWatchlistDoc = await watchlistCollection.findOne({
movieId,
});
if (existingWatchlistDoc) {
const activeEntry = existingWatchlistDoc as WatchlistDoc;
if (activeEntry.active) {
res.status(200).json({
document: activeEntry,
alreadyActive: true,
reactivated: false,
});
return;
}
const now = new Date();
await watchlistCollection.updateOne(
{ movieId },
{ $set: { active: true, updatedAt: now } }
);
const reactivated: WatchlistDoc = {
...activeEntry,
active: true,
updatedAt: now,
};
res.status(200).json({
document: reactivated,
alreadyActive: false,
reactivated: true,
});
return;
}
const movieFromCatalog = await moviesCollection.findOne(
{ _id: movieId },
{ projection: { title: 1, poster: 1 } }
);
if (!movieFromCatalog) {
throw new Error("Movie not found in sample_mflix");
}
const { title, poster } = movieFromCatalog;
const now = new Date();
const newWatchlistDocument = {
movieId,
title,
poster,
active: true,
createdAt: now,
updatedAt: now,
};
const insertResult = await watchlistCollection.insertOne(
newWatchlistDocument
);
const document: WatchlistDoc = {
_id: insertResult.insertedId,
...newWatchlistDocument,
};
res.status(201).json({
document,
alreadyActive: false,
reactivated: false,
});
} catch (err) {
next(err);
}
});
To break down each stage of the above endpoint, we're going to start with the initial query:
const existingWatchlistDoc = await watchlistCollection.findOne({
movieId,
});
The movieId should have been passed in with the request payload. It should represent a valid movie id from the movies collection. After executing the query, we check to see if results came back, and if they did, it means this particular movie is already in the watchlist. If the movie is already in the watchlist and it is in the active state, we can just return that information now. If it is in the watchlist, but inactive, we want to reactivate it and update the timestamp before returning it.
Finally, we have this code within the endpoint:
const movieFromCatalog = await moviesCollection.findOne(
{ _id: movieId },
{ projection: { title: 1, poster: 1 } }
);
if (!movieFromCatalog) {
throw new Error("Movie not found in sample_mflix");
}
const { title, poster } = movieFromCatalog;
const now = new Date();
const newWatchlistDocument = {
movieId,
title,
poster,
active: true,
createdAt: now,
updatedAt: now,
};
const insertResult = await watchlistCollection.insertOne(
newWatchlistDocument
);
const document: WatchlistDoc = {
_id: insertResult.insertedId,
...newWatchlistDocument,
};
res.status(201).json({
document,
alreadyActive: false,
reactivated: false,
});
If the movie is not in the watchlist already, we first need to query the movies collection for information about the movie based on the provided movieId value. With the title and poster information, we can construct a new watchlist document and then insert it into our collection.
For this particular project, we won't worry about keeping our movie posters up to date. We're just going to assume they never change.
The next endpoint we'll look at is a GET endpoint for listing all the movies in our watchlist:
router.get("/", async (req, res, next) => {
try {
const database = Database.instance.db;
const raw = req.query.active;
const activeOnly =
raw === undefined
? true
: !["false", "0"].includes(String(raw).toLowerCase());
const watchlistCollection = database.collection(
config.watchlistCollection
);
const items = await watchlistCollection
.find({ active: activeOnly })
.sort({ updatedAt: -1 })
.toArray();
res.json({ active: activeOnly, items });
} catch (err) {
next(err);
}
});
This particular endpoint will accept an active query parameter, allowing the client to request watchlist items that are either active or have been soft-deleted (inactive).
When we run our MongoDB query, we are sorting by the updatedAt timestamp so the newest additions to the watchlist appear first. We aren't doing a $project here because our documents are small, but you could easily define which fields are important to you for returning.
The next endpoint is our PATCH endpoint, which is responsible for assigning a user rating to a movie in the watchlist:
router.patch("/:movieId/rating", async (req, res, next) => {
try {
const database = Database.instance.db;
const movieId = new ObjectId(req.params.movieId);
const stars = Number(req.body?.stars);
if (!Number.isInteger(stars) || stars < 1 || stars > 5) {
throw new Error("Rating must be an integer from 1 to 5");
}
const watchlistCollection = database.collection(
config.watchlistCollection
);
const now = new Date();
const updateResult = await watchlistCollection.updateOne(
{ movieId },
{ $set: { rating: stars, updatedAt: now } }
);
if (updateResult.matchedCount === 0) {
throw new Error(
"Movie is not on the watchlist; only watchlist movies can be rated"
);
}
const updatedWatchlistDoc = await watchlistCollection.findOne({
movieId,
});
if (!updatedWatchlistDoc) {
throw new Error(
"Failed to load watchlist item after rating update"
);
}
res.json({ document: updatedWatchlistDoc });
} catch (err) {
next(err);
}
});
Here, we accept a movie id as a route parameter and a star rating within the request payload.
Using the updateOne operator, we can attempt to update the document. If the matchCount after the operation is zero, it means that there wasn't a document to update. This is likely because the movie id was incorrect, or it wasn't in our watchlist. When adding a rating, we could just respond with a simple message stating the update was complete, but in this example, we are querying for the watchlist item and returning what appears in the document.
The two final endpoints for our watchlist include removing a rating and soft-deleting a watchlist item.
Starting with the rating removal, we have the following DELETE request:
router.delete("/:movieId/rating", async (req, res, next) => {
try {
const database = Database.instance.db;
const movieId = new ObjectId(req.params.movieId);
const watchlistCollection = database.collection(config.watchlistCollection);
const now = new Date();
const updateResult = await watchlistCollection.updateOne(
{ movieId },
{ $unset: { rating: "" }, $set: { updatedAt: now } }
);
if (updateResult.matchedCount === 0) {
throw new Error(
"Movie is not on the watchlist; only watchlist movies can be rated"
);
}
const updatedWatchlistDoc = await watchlistCollection.findOne({
movieId,
});
if (!updatedWatchlistDoc) {
throw new Error(
"Failed to load watchlist item after rating removal"
);
}
res.json({ document: updatedWatchlistDoc });
} catch (err) {
next(err);
}
});
The above code will take a movieId and make use of the $unset operator within an updateOne operation. This will remove the rating. Just like with the previous endpoint, we could have opted to return a message about the operation’s success, but here we are querying for the full document and returning it to the client instead.
The final endpoint in our route looks like the following:
router.delete("/:movieId", async (req, res, next) => {
try {
const database = Database.instance.db;
const movieId = new ObjectId(req.params.movieId);
const watchlistCollection = database.collection(config.watchlistCollection);
const existingWatchlistDoc = await watchlistCollection.findOne({
movieId,
});
if (!existingWatchlistDoc) {
throw new Error("Watchlist item not found");
}
if (!(existingWatchlistDoc as WatchlistDoc).active) {
res.status(204).send();
return;
}
const now = new Date();
await watchlistCollection.updateOne(
{ movieId },
{ $set: { active: false, updatedAt: now } }
);
res.status(204).send();
} catch (err) {
next(err);
}
});
Similar logic applies here. If the watchlist item exists and it is not already inactive, we do an updateOne operation and $set the active status to inactive. In this example, we are just doing an empty return with an appropriate status to show it was deactivated.
Configuring Express Framework to Connect to MongoDB and Serve the Application
At this point, our two routes are in place, and the appropriate indexes have been configured. We need to be able to serve our application so clients of any shape can interact with it.
Open the project's src/app.ts file and include the following:
import express, { type ErrorRequestHandler } from "express";
import { moviesRouter } from "./routes/movies";
import { watchlistRouter } from "./routes/watchlist";
import { config } from "./config";
import { Database } from "./db";
async function main() {
await Database.connect();
const app = express();
app.use(express.json());
app.get("/health", (_req, res) => {
res.json({ ok: true });
});
app.use("/movies", moviesRouter());
app.use("/watchlist", watchlistRouter());
const onError: ErrorRequestHandler = (err, _req, res, next) => {
if (res.headersSent) {
next(err);
return;
}
console.error(err);
res.status(500).json({
error: err instanceof Error ? err.message : String(err),
});
};
app.use(onError);
const server = app.listen(config.port, () => {
console.log(`Listening on http://localhost:${config.port}`);
});
const shutdown = async () => {
server.close();
await Database.instance.close();
process.exit(0);
};
process.on("SIGINT", () => void shutdown());
process.on("SIGTERM", () => void shutdown());
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
In the above code, we are configuring Express Framework to accept JSON payloads and to recognize our two route files. Skipping beyond the error handling and listening of our backend, you'll find the shutdown logic.
When a shutdown is triggered, we close the server from listening for connections, and we also close the connection to our MongoDB database. This is good practice for making sure everything is gracefully terminated.
Running the API to Test the Endpoints
For this particular tutorial, we are not exploring a frontend. However, we'll run and test our API endpoints so you can get an idea of what it looks like.
From a command line, execute the following to run the application in development mode:
tsx watch src/app.ts
With the application running, you can first test the health check endpoint:
curl -s http://localhost:3000/health
If that looks good, try searching for a movie:
curl -s "http://localhost:3000/movies/search?q=Terminator"
Replace the q with whatever value you'd like to search for. Make note of one of the movie id values in the response because you'll need it for populating your watchlist with the following command:
curl -s -X POST http://localhost:3000/watchlist \
-H "Content-Type: application/json" \
-d '{"movieId":"<PASTE_OBJECT_ID_HERE>"}'
Go ahead and give the other endpoints a test!
It's worth mentioning that what we built is good for a demo, but it will need some work for production. We didn't do much data validation, our error handling needs work, and we have no cross-origin resource sharing (CORS) policy in place to define who is allowed to access our application. However, it should get you started when working with MongoDB in unique ways.
Conclusion
You just saw how to use various features of MongoDB, such as MongoDB Search and CRUD, to build a movie watchlist application with ratings. We made use of MongoDB Search for full-text search functionality, and we used CRUD to process documents in our watchlist. We also saw that it is alright to duplicate data between the movies collection and the mflix_watchlist collection to boost performance and avoid JOIN operations.
To take this project to the next level, try adding a frontend or a few more watchlist features. Maybe you can include MongoDB Vector Search to come up with movie recommendations based on your watchlist items and ratings.
Want to try this project for yourself? Clone the repository on GitHub!
Top comments (0)