Developing Full Stack Next.js Web Applications
Chapter 6 — Integrating React with MongoDB
Dr. Jose Annunziato
There are two main categories of databases: relational databases and non-relational databases. Relational databases such as MySQL, SQL Server, and Postgres store data in tables containing records of the same type. A table called courses would contain records representing all the courses, and a users table would contain all the users of an application. Records are represented as rows in the tables where each column stores data for attributes specific to the type of the table. The rows in the courses table might have columns such as name, description, startDate, endDate, and so on. Some of the columns might refer, or relate, to other records in other tables. The instructor column in the courses table might refer to a particular row in the users table, signifying that that particular user is the instructor of that particular course. Rows in one table relating to rows in another table is where relational databases get their name. The structured query language, or SQL, is a computer language commonly used to interact with relational databases. The query in SQL generally means to ask for, or retrieve, data that matches some criteria, often written as a boolean expression or predicate. You might ask for every course whose startDate is after a given day, or every user whose role equals FACULTY, and the database engine evaluates that predicate against every row before returning the matching set.
More recently there has been a growing interest in representing and storing data using alternative strategies which have collectively come to be referred to as non-relational databases, or NoSQL databases. Non-relational databases such as MongoDB, Firebase, and Couchbase store their data in collections containing documents which are roughly analogous to tables and records in their relational counterparts. The biggest difference is that the columns, or fields, in the rows in relational databases generally can only contain primitive data types — simple strings, numbers, dates, and booleans — whereas the fields, or properties, in non-relational documents can be arbitrarily complex data types: strings, numbers, booleans, and dates as well as combinations of these in complex objects containing arrays of objects of arrays, and so on. A single course document can hold a nested array of modules, and each module can hold a nested array of lessons, without first creating three separate tables and wiring them together with foreign keys. The other big difference is that relational databases require the structure, or schema, of the data to be explicitly described before storing any data, whereas non-relational databases do not require predefined schemas. Instead, non-relational databases delegate this responsibility to the applications using the database. The structure, or schema, in relational databases is where structured query language gets its name. MongoDB will happily store two documents in the same collection that do not share the same fields; it is your Node.js application — later through Mongoose schemas — that decides which shapes are valid.
In the previous chapter we learned how to create an HTTP server with Node.js and Express and integrated it with a React Web user interface application to store the application state on the server. That sibling webdev-server project on port 4000 still holds courses, modules, users, and enrollments in JavaScript arrays seeded from the JSON files you first imported in Chapter 3. Restart Node and those arrays reseed from disk; nothing you created in the Dashboard survives a reboot. This chapter expands on that idea to store the data in MongoDB, a popular non-relational database whose documents are usually formatted as JSON objects, which makes it very convenient to integrate with JavaScript-based frameworks such as Node.js and React. The first section demonstrates how to download, install, and use a local instance of the MongoDB database. The next section covers how to use the Mongoose library to integrate and program a MongoDB database with a Node.js server application. Later sections describe how to deploy the database to MongoDB Atlas, a remote MongoDB database hosted as a cloud service, and how to migrate the Kambaz collections themselves so Dashboard and the course screens read and write documents instead of arrays.
The following architecture is what we will be building in this chapter. Reading from right to left, we first create a MongoDB database called kambaz where we create several collections such as users, courses, modules, assignments, and enrollments. We use the Mongoose library to connect to the database programmatically from a Node server. A Mongoose schema describes the structure of the collections in the MongoDB database and a Mongoose model implements generic CRUD operations. We create higher-level functions in Data Access Objects (DAOs) that operate on the database, and expose those operations through Express routes as RESTful Web APIs. A React Web app integrates with the RESTful API through a client that allows the user interface to interact with the database. The URLs you already call from Chapter 5 do not change; only the implementation behind findAllUsers and createCourse moves from an in-memory array to a collection. §6.1 installs a local instance and Compass. §6.2 connects with Mongoose: schemas, models, DAOs, then CRUD routes the React client already calls. §6.3 points the same connection string at Atlas and a new Render service. §6.4 migrates courses, modules, enrollments, and assignments.
6.1 Working with a Local MongoDB Instance
SlidesMongoDB is one of an increasingly popular family of non-relational databases. Data is stored in collections of documents usually formatted as JSON objects which makes it very convenient to integrate with JavaScript-based frameworks such as Node.js and React. You can open a document in Compass and recognize the same _id, username, and role fields you already mapped in the Kambaz Account screens. This section describes how to install, configure, and get started using MongoDB on your own machine before any Node code talks to it. Glance at http://localhost:3000/labs/lab6 as you go — the LiveDemos speak the same API the Express DAOs implement once the connection string is set.
6.1.1 Installing and Configuring MongoDB
To get started, download MongoDB for free from the MongoDB Community Server download page, selecting the latest version for your operating system, and click Download. Run the installer and, if given the choice, choose to run the database as a service so that you do not have to bother restarting the database server every time you log in or restart your computer. The MongoDB database will automatically start whenever you start your computer, which is the arrangement you want while you work through this chapter and the next assignment. On Windows, confirm the database is running by searching for MongoDB in the Services dialog. On macOS, confirm the database is running by clicking the MongoDB icon in the System Settings dialog. The service dialog gives you controls to start and stop the database, but it should already be configured to start automatically when you restart your computer. Leave the service set to start automatically unless you are deliberately experimenting with mongod from the command line in the next optional subsection.
6.1.1.1 Installing MongoDB Manually (optional)
On macOS you can install MongoDB using Homebrew by typing the following at the command line. The Atlas CLI walkthrough can also stand up a local sandbox if you prefer not to manage a downloaded archive yourself:
brew install mongodb-atlas
atlas setupAlternatively you can unzip the MongoDB server from the downloaded archive to a local file system and add the right commands to your operating system PATH environment variable. On macOS, unzip the file into /usr/local which creates a directory such as /usr/local/mongodb-macos-x86_64-8.0.4 (your version might differ). To be able to execute the database related commands, add the path to the .bash_profile or .zshrc file located in your home directory. Add the following line in the configuration file as shown below. Your actual version might differ.
# ~/.bash_profile or ~/.zshrc
export PATH="$PATH:/usr/local/mongodb-macos-x86_64-8.0.4/bin"If the .bash_profile or .zshrc file does not exist in your home directory, create it as a plain text file, but with no extensions and a period in front of it. Configure it as shown above and then restart your computer so the shell picks up the new path. After the restart, which mongod should print a path under that bin directory.
On Windows, unzip the file into C:\Program Files. To configure environment variables on Windows press the Windows + R key combination to open the Run prompt, type sysdm.cpl and press OK. In the System Properties window that appears, press the Advanced tab and then the Environment Variables button. In the Environment Variables configuration window select the Path variable and press the Edit button. Copy and paste the path of the bin directory in the mongodb directory you unzipped, for example C:\Program Files\mongodb-windows-x86_64-8.0.4\bin. The actual path might differ. Press OK and restart the computer so every new terminal session can find mongod and mongosh.
6.1.1.2 Starting MongoDB from the Command Line
If you installed MongoDB as a service, it is already running in the background and can be configured and restarted in Windows from the Services dialog or from System Settings on macOS. Alternatively you can start the MongoDB server from the command line using the mongod executable in the bin directory where you installed MongoDB. First you will need to create a data folder where the server will store all its data. You can create a data folder in your home directory as shown below.
cd ~
mkdir dataWhen you start MongoDB, you will need to tell it where the data folder is with the dbpath option. If you installed MongoDB on Windows under Program Files, you can start MongoDB from your home directory as shown below. Make sure to include the --dbpath data option to tell MongoDB where to find the data directory. Leave that terminal open while you work; closing it stops the process unless the installer registered a service.
cd ~
# Windows (path and version will differ)
# C:\Program Files\mongodb-windows-x86_64-8.0.4\bin\mongod --dbpath data
# macOS
# /usr/local/mongodb-macos-aarch64-8.0.4/bin/mongod --dbpath dataIf you installed MongoDB on macOS in /usr/local, the second command is the one you want. The version folder name changes with each release; copy the path you actually unzipped rather than the sample numbers. Once mongod prints that it is waiting for connections on port 27017, Compass and later Mongoose can reach it.
6.1.2 Using MongoDB Compass to Interact with MongoDB
Your installation should have installed MongoDB Compass, a user interface client to the MongoDB database. If not, MongoDB Compass can be downloaded from MongoDB's download page. You can start Compass from your applications folder, or search for it in your operating system's search feature. On macOS bring up Spotlight by pressing the magnifying glass on the top right menu bar, or press Command and Spacebar. Type MongoDB Compass in the search bar and select the application from the result list. On Windows press the Windows key to bring up the search field, type MongoDB Compass, and select the application from the result list. When Compass comes up, confirm that the connection string mongodb://127.0.0.1:27017 appears in the New Connection screen, and press Connect to connect to MongoDB. That URI is the same host and port Mongoose will use in §6.2.1 when the Node server talks to the local instance. If Connect fails, go back to the service dialog or the mongod terminal and confirm the process is actually listening before you try again.
6.1.3 Creating a MongoDB Database
Once connected to a running MongoDB server, click on the connection on the left sidebar and then click the Create database button in the tab on the right. In the Create Database dialog that appears, name your database kambaz and your first collection as users. Click Create Database to create the kambaz database. MongoDB creates a database when the first collection is created, so you always supply both names together. The collection can start empty; the next subsection inserts documents by hand and then imports the JSON files you already used for Kambaz. Keep the name kambaz exactly, because the connection string in §6.2.1 ends with /kambaz and Atlas later expects the same path segment between the last slash and the question mark.
6.1.4 Inserting and Retrieving Data with Compass
In MongoDB, data is organized into collections, which are analogous to tables in relational databases. Data contained in collections are referred to as documents, which are analogous to records in relational databases. To create, or insert, documents into a collection in a MongoDB database using Compass, select the database on the left sidebar and then select the collection to insert documents into. For instance, select the kambaz database and then the users collection. On the right side, select ADD DATA and then Insert document. In the Insert Document dialog that appears, paste a user shaped like the objects in app/(kambaz)/database/users.json from §3.9.2 — an _id, username, password, firstName, lastName, role, and the other fields the Account screens already display. Click Insert to insert the document and confirm the document inserted as expected. Compass shows the new row in the Documents tab; you can expand it to inspect every field.
Instead of inserting one document at a time, entire JSON files can be imported all at once. Import the users.json file we used in earlier chapters under the Database directory of the React project. To import, click ADD DATA, but now select Import JSON or CSV file. Navigate to the location of users.json, select the file, and click Import. Confirm the users are imported. Also find the following collections and import the JSON files linked to each of the collection names. Confirm all collections are imported: modules.json, assignments.json, courses.json, and enrollments.json. Create a collection for each file if Compass does not already list it, then import into that collection so the names match the Mongoose collection options you will write in §6.2.3 and §6.4. Confirm the document counts match the files. Open one course document and confirm name and _id look like the React database; those string identifiers are why the user schema later declares _id as a String instead of letting MongoDB invent ObjectIds.
6.2 Programming with a MongoDB Database
SlidesIn the previous section we practiced interacting with the MongoDB database through the Compass graphical interface. That is all well and good to make occasional simple manual updates and queries to confirm the data behaves as expected, but applications need to interact with the database programmatically with libraries such as Mongoose. Compass is a human tool; Mongoose is the library your Node process uses so Sign in, Dashboard, and the Users screen can create, read, update, and delete documents without anyone clicking Insert. The following sections describe how to install, configure, and connect a Node.js application to a MongoDB database server using the Mongoose library. The final part of this chapter discusses how to configure the application to integrate to a MongoDB database hosted in MongoDB's Atlas cloud service, and how to point a new Render deployment at that remote URI. Do all your work in a new GitHub branch called a6 in both the React and Node.js projects — webdev-client and the sibling webdev-server.
LiveDemos in this book call same-origin /api/lab6, which implements the Express Lab 6 contract with an in-memory store so pages render when mongod is not running. The teaching code below is the sibling server. When DATABASE_CONNECTION_STRING (or MONGO_CONNECTION_STRING) is set and reachable, those DAOs use Mongoose; otherwise they keep the Chapter 5 arrays. The prose is written as if the database is connected — that is the path you will run locally tomorrow and on Atlas after §6.3.
6.2.1 Installing and Connecting to a MongoDB Database
The Mongoose library implements a set of APIs and abstractions for applications to interact with a MongoDB database. Instead of opening a raw driver connection and writing collection names as strings in every call, you declare schemas and models once and then call find, create, updateOne, and deleteOne on those models. To use the Mongoose library, install it from the root of the Node.js project as shown below.
cd webdev-server
npm install mongooseTo connect to the database server programmatically, import the Mongoose library and then use the connect function as shown below. The URL in the connect function is called the connection string and is currently referring to a MongoDB server instance running on the localhost machine — the current laptop or desktop — listening at port 27017 and the kambaz database existing in that server, the same instance Compass just opened. In a later section we will revisit the connection string and configure it to connect to a database server running in a remote machine hosted by MongoDB's Atlas cloud service.
import express from "express";
import mongoose from "mongoose";
// load the mongoose library
// ...
const CONNECTION_STRING = "mongodb://127.0.0.1:27017/kambaz";
mongoose.connect(CONNECTION_STRING);
// connect to the kambaz database
const app = express();
// ...Place the connect call near the top of index.js so the connection is established before any route handler tries to run a query. This book wraps that call in connectDatabase() so a missing or dead URI does not crash CI. The string you write as a student is the one above. If mongod is not running, Mongoose will retry and then fail; start the service or the command-line process from §6.1.1 before you start nodemon.
6.2.2 Configuring Connection Strings as Environment Variables
Instead of hard coding the connection string in the source code, it is better to configure it as an environment variable and then reference it from the code. This will come in handy when the server application is deployed to a remote service such as Render or Heroku and the connection string can be configured to reference the online remote database running on MongoDB's Atlas cloud service. You will create that cluster in §6.3; for now the local URI is enough, and the same variable name will later hold the mongodb+srv:// string. In the .env file of the Node project — not the Next.js .env.local — declare the following connection string environment variable alongside the session keys you already set in Chapter 5.
SERVER_ENV=development
CLIENT_URL=http://localhost:3000
SERVER_URL=http://localhost:4000
SESSION_SECRET=super secret session phrase
DATABASE_CONNECTION_STRING=mongodb://127.0.0.1:27017/kambazThen in index.js, read the connection string as shown below. The dotenv/config import loads .env into process.env before any other module reads those keys. If the variable is missing, the fallback keeps you on localhost so a forgotten line does not silently point at nothing.
import "dotenv/config";
import mongoose from "mongoose";
const CONNECTION_STRING =
process.env.DATABASE_CONNECTION_STRING ||
"mongodb://127.0.0.1:27017/kambaz";
mongoose.connect(CONNECTION_STRING);The PDF name is DATABASE_CONNECTION_STRING. This repo also accepts MONGO_CONNECTION_STRING. If neither is set, DAOs stay in memory and /lab6/status reports store: "memory". After you add the line and restart nodemon, the status demo below should report a database connection when Mongo is reachable. In §6.3.2 you will type the same key into the Render Environment dashboard with an Atlas URI as the value, and you will not change this source file again.
MongoDB connection
Students set DATABASE_CONNECTION_STRING on Express. This demo reports the same-origin Lab 6 store the book uses when Mongo is not configured.
Click to read the store.
6.2.3 Implementing Mongoose Schemas and Models
As mentioned earlier, non-relational databases do not require specifying the structure, or schema, of the data stored in collections like relational databases do. That responsibility has been delegated to applications using non-relational databases. Once a Node.js application establishes a connection to a MongoDB database, the Mongoose API declares datatypes Schemas and Models to interact with collections. Mongoose Schemas describe the structure of the data being stored in the database and are used to validate the data being stored or modified through the Mongoose library. If a route tries to insert a user without a username, Mongoose rejects the write before it reaches the collection. The schema shown below describes the structure for the users collection imported earlier. Create the schema in a Users directory in your Node.js project.
import mongoose from "mongoose";
const userSchema = new mongoose.Schema({
_id: String,
username: { type: String, required: true, unique: true },
password: { type: String, required: true },
firstName: String,
email: String,
lastName: String,
dob: Date,
role: {
type: String,
enum: ["STUDENT", "FACULTY", "ADMIN", "USER", "TA"],
default: "USER",
},
loginId: String,
section: String,
lastActivity: Date,
totalActivity: String,
},
{ collection: "users" });
export default userSchema;Walk the fields one by one so the later DAO and Users screen make sense. _id is the primary key. We keep it a String so the identifiers from earlier JSON files still work; if we omitted it, MongoDB would assign ObjectIds and every enrollment that stored "123" would stop matching. username is a string that is required and unique — two signups cannot share it, which is the same rule the signup route already enforced in memory. password is required but not unique; several people may coincidentally pick the same password, and this course stores it in plain text only so Sign in can compare strings the way Chapter 5 did. firstName, lastName, and email are plain strings with no extra configuration; they may be empty on a newly created user until someone edits them in People Details. dob is a Date so Compass and Mongoose agree on a calendar value rather than a free-form string. role is a string restricted by enum to STUDENT, FACULTY, ADMIN, USER, and TA, with a default of USER when the client does not send a role — that default is what keeps a forgotten field from becoming undefined in the Account Navigation check. loginId and section are strings the People table displays. lastActivity is a date and totalActivity is a string so the table can show a duration such as 10:21:32 without forcing a numeric type. The second argument, { collection: "users" }, tells Mongoose to store documents in the users collection you created in Compass rather than inventing a pluralized default name.
6.2.4 Implementing Mongoose Models
Mongoose models implement a low-level API to interact with MongoDB collections programmatically. Models provide CRUD (Create, Read, Update, Delete) functions such as find(), create(), updateOne(), deleteOne(), and findById(). Those names are deliberately generic because they can interact with any collection configured in the schema. In Kambaz/Users/model.js below, create a Mongoose model from the users schema. In the next section we will create a data access object that implements higher-level functions specific to the domain of Kambaz —findUserByCredentials instead of a bare findOne.
import mongoose from "mongoose";
import schema from "./schema.js";
const model = mongoose.model("UserModel", schema);
export default model;The first argument, "UserModel", is the model name other schemas use in ref — enrollments will point at it in §6.4.3 when populate("user") needs to know which model to load. The second argument is the schema you just wrote. Exporting the model lets the DAO import a single object and call model.find() without repeating the collection name. You do not instantiate the model yourself; Mongoose keeps one compiled model per name for the life of the process.
6.2.5 Retrieving Data from MongoDB with Mongoose
The Mongoose model created in the previous section provides low-level functions such as find, create, updateOne, and deleteOne that are deliberately vague since they need to be able to operate on any collection. It is good practice to wrap these low-level generic functions within higher-level functions that are specific to the use cases of the specific project. For instance, instead of just using the generic find() function, it would be preferable to use something such as findUsers(), findUserById(), or findUserByUsername(). A previous chapter implemented a data access object using arrays declared in the Database/index.js files. This chapter refactors the DAOs so they use an actual database. Chapter 5's DAO read arrays from that barrel file. This chapter keeps the same function names and reimplements them with the model. The following Kambaz/Users/dao.js re-implements the CRUD operations for the users collection written in terms of the low-level Mongoose model operations.
import model from "./model.js";
import { v4 as uuidv4 } from "uuid";
export default function UsersDao() {
const findAllUsers = () => model.find();
const findUserById = (userId) => model.findById(userId);
const findUserByUsername = (username) =>
model.findOne({ username: username });
const findUserByCredentials = (username, password) =>
model.findOne({ username, password });
const updateUser = (userId, user) =>
model.updateOne({ _id: userId }, { $set: user });
const deleteUser = (userId) => model.deleteOne({ _id: userId });
const createUser = (user) => {
const newUser = { ...user, _id: uuidv4() };
return model.create(newUser);
};
return {
createUser, findAllUsers, findUserById,
findUserByUsername, findUserByCredentials, updateUser, deleteUser,
};
}findAllUsers is model.find() with no predicate, so it returns every document in users. findUserById uses findById, which is the usual way to load one document by primary key. findUserByUsername calls findOne with { username } so signup can reject a duplicate. findUserByCredentials matches both username and password in one query, which is what Sign in needs. updateUser identifies the document by _id and applies $set so only the fields in the payload change; fields you omit stay as they were. deleteUser removes one document by primary key. createUser copies the incoming object, assigns a fresh uuidv4() identifier, and inserts it; we generate the id in the application so the string format stays compatible with enrollments. Each of these functions returns a promise. Routes in the next section will await them. Practice the same verbs on a small todos collection before you touch Kambaz users. Create, find, find by id, update, and delete — that is every CRUD letter:
Lab 6 todos (Mongoose CRUD)
6.2.6 Implementing APIs to Interact with MongoDB from a React Client Application
SlidesDAOs implement an interface between an application and the low-level database access, providing a high-level API to the rest of the application and hiding the details and idiosyncrasies of using a particular database vendor. Likewise routes implement an interface between the HTTP network world and the JavaScript functional programming world by converting a stream of bits from a network connection request into a set of objects, maps, and function event handlers that participate in the client/server architecture of a multi-tiered application. The browser never imports Mongoose; it posts to /api/users/signin and receives JSON. The following sections demonstrate implementing the most common CRUD (Create, Read, Update, and Delete) database operations including retrieving all documents, retrieving documents by predicate, retrieving documents by primary key, deleting a document, updating a document, and creating a new document. Each operation is implemented three times: once in the DAO, once as an Express route, and once as an axios client function the React screens already import from app/(kambaz)/account/client.ts.
6.2.6.1 Refactoring Account Routes
Previous chapters implemented account routes such as signin and signup shown below. Since the DAO implementations used data structures imported from the local file system, the operations were synchronous. A find against an in-memory array returned a user before the next line ran, so the handler could assign req.session["currentUser"] immediately. Now that the DAO is interacting with a database, the operations are asynchronous: Mongoose sends a query over the network — even to localhost — and the answer arrives later as a promise. The route handlers must be tagged with the async / await keywords as shown below so Express does not send a response before the database has answered. Confirm Signin, Signup, and Profile screens work as before. Following the examples below for the signin and signup functions, add the async keyword to all other router functions and add the await keyword to all calls to DAO functions, including profile, signout, and the course routes you wrote in Chapter 5.
const signin = async (req, res) => {
const { username, password } = req.body;
const currentUser = await dao.findUserByCredentials(username, password);
if (currentUser) {
req.session["currentUser"] = currentUser;
res.json(currentUser);
} else {
res.status(401).json({ message: "Unable to login. Try again later." });
}
};
const signup = async (req, res) => {
const user = await dao.findUserByUsername(req.body.username);
if (user) {
res.status(400).json({ message: "Username already taken" });
return;
}
const currentUser = await dao.createUser(req.body);
req.session["currentUser"] = currentUser;
res.json(currentUser);
};Signin still reads username and password from the request body, but now it awaits findUserByCredentials. If a matching document exists, the handler stores that document on the session and returns it as JSON so the React Sign in screen can call setCurrentUser on Account Context from Chapter 4. If no document matches, the handler responds 401 with the same message you already display. Signup first awaits findUserByUsername; if that query returns a document, the username is taken and the handler returns 400 without inserting anything. Otherwise it awaits createUser, stores the new document on the session, and returns it. The React screens do not change their axios URLs; they only continue to work if every DAO call is awaited. After you tag the remaining handlers, sign in as iron_man / stark123, open Profile, and confirm the name still comes from the server. Then sign out and sign up a throwaway username to confirm the duplicate-username branch still rejects a second attempt.
6.2.6.2 Retrieving All Documents from MongoDB with Mongoose
DAOs implement high-level data operations based on lower-level Mongoose models. The Mongoose model find function retrieves all documents from a collection when you pass no predicate. The higher-level findAllUsers function below uses the lower-level find function to retrieve all the users from the users collection. That is the same function you already returned from the DAO in §6.2.5; we repeat it here because the route, the client, and the Users screen all depend on it.
const findAllUsers = () => model.find();Routes implement RESTful Web APIs that user interface clients can use to interact with server functionality. The route implemented below uses the findAllUsers function implemented by the DAO to retrieve all the users from the database. The route responds with the collection of users retrieved from the database. Confirm the route works by navigating to http://localhost:4000/api/users with the browser. You should see a JSON array whose length matches the document count Compass shows for kambaz.users. If the array is empty, import the JSON files from §6.1.4 again and refresh.
const findAllUsers = async (req, res) => {
const users = await dao.findAllUsers();
res.json(users);
};
app.get("/api/users", findAllUsers);Meanwhile in the React user interface application, in app/(kambaz)/account/client.ts, implement the findAllUsers function shown below to send a GET request to the server and await the server's response containing an array of users in the data property. Use the axios instance that sends credentials so a later admin-only check can read the session cookie. The USERS_API constant already prefixes httpServer() or NEXT_PUBLIC_HTTP_SERVER so the same function works on localhost and on Vercel once you point the public env var at the new Render origin.
export const findAllUsers = async () => {
const response = await axiosWithCredentials.get(USERS_API);
return response.data;
};To display the array of users from the database, refactor the People Table component to accept an optional users parameter instead of retrieving the users from the local file system. Convert the People Table from a page into a component as shown below. Remove any filters that joined enrollments in the browser, since data access rules are best handled at the server. The table should render whatever array the parent passes; the Users screen will pass every user, and the course People page in §6.4.3.5 will pass only the students enrolled in that course. Style the table with Tailwind utility classes rather than Bootstrap's table table-striped — a full-width collapsed table with a light border and striped odd rows is enough.
"use client";
import { useState } from "react";
import { FaUserCircle } from "react-icons/fa";
import PeopleDetails from "./Details";
export default function PeopleTable({
users = [],
fetchUsers,
}: {
users?: any[];
fetchUsers: () => void;
}) {
return (
<div id="wd-people-table" className="overflow-x-auto">
<table className="w-full border-collapse text-left text-sm">
<thead>
<tr className="border-b border-neutral-300">
<th className="p-2">Name</th>
<th className="p-2">Login ID</th>
<th className="p-2">Section</th>
<th className="p-2">Role</th>
<th className="p-2">Last Activity</th>
<th className="p-2">Total Activity</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user._id} className="odd:bg-neutral-50">
<td className="wd-full-name p-2 text-nowrap">
<FaUserCircle className="me-2 inline text-3xl text-neutral-500" />
<span className="wd-first-name">{user.firstName}</span>{" "}
<span className="wd-last-name">{user.lastName}</span>
</td>
<td className="wd-login-id p-2">{user.loginId}</td>
<td className="wd-section p-2">{user.section}</td>
<td className="wd-role p-2">{user.role}</td>
<td className="wd-last-activity p-2">{user.lastActivity}</td>
<td className="wd-total-activity p-2">{user.totalActivity}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}Create a new Users screen that fetches the users from the database and displays them with the People Table component as shown below. The screen is a Client Component because it keeps the array in useState and loads it in useEffect when the page mounts. fetchUsers calls the client, stores the array, and is later passed into the table so People Details can refresh the list after a create, update, or delete.
"use client";
import { useState, useEffect } from "react";
import PeopleTable from "../../courses/[cid]/people/Table";
import * as client from "../client";
export default function Users() {
const [users, setUsers] = useState<any[]>([]);
const fetchUsers = async () => {
const users = await client.findAllUsers();
setUsers(users);
};
useEffect(() => {
fetchUsers();
}, []);
return (
<div>
<h3>Users</h3>
<PeopleTable users={users} fetchUsers={fetchUsers} />
</div>
);
}Add a Users link to the Account Navigation sidebar that navigates to the Users screen only if the logged-in user has an ADMIN role. The signed-in user lives in Account Context from Chapter 4, not in a Redux slice, so read currentUser from useAccountContext(). Render the link with the same Tailwind pattern the other Account links already use: semibold black when the path ends with users, otherwise the red accent. To test, use Compass to update the role of an existing user or create a new user with an ADMIN role, sign in as that ADMIN user, and navigate to the Users screen. This book seeds nick_fury / fury123 as an administrator. Confirm that all users are displayed.
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAccountContext } from "./AccountContext";
export default function AccountNavigation() {
const { currentUser } = useAccountContext();
const pathname = usePathname() ?? "";
return (
<div id="wd-account-navigation">
{/* existing Signin / Signup / Profile links */}
{currentUser && currentUser.role === "ADMIN" && (
<>
<Link
href="/account/users"
className={
pathname.endsWith("users")
? "font-semibold text-black"
: "text-red-600"
}
>
Users
</Link>
<br />
</>
)}
</div>
);
}Logged in as an ADMIN, navigate to the new Users screen and confirm it displays all the users. A faculty or student session should not show the link at all. If you see an empty table, open the Network tab and confirm GET /api/users returns 200 and a non-empty array; a 401 usually means the session cookie was not sent, which is why the client uses axiosWithCredentials.
6.2.6.3 Retrieving Documents by Predicate from MongoDB with Mongoose
SlidesListing every user is useful for an administrator, but the moment the collection grows you will want to ask for a subset. Mongoose's find accepts a JSON object used to pattern-match documents in the collection. That object is a predicate: only documents that satisfy it are returned. In the User DAO, implement findUsersByRole that filters the users collection by the role property as shown below. The { role: role } object means that documents will be filtered by their role property that matches the value role. Because the property name and the variable name are the same, you can write the shorthand { role } and Mongoose will still compare the field to that string.
const findUsersByRole = (role) => model.find({ role });
const findUsersByPartialName = (partialName) => {
const regex = new RegExp(partialName, "i");
return model.find({
$or: [
{ firstName: { $regex: regex } },
{ lastName: { $regex: regex } },
],
});
};findUsersByPartialName goes a step further. Instead of an exact string match, it builds a regular expression from the text the administrator typed and asks MongoDB to match that pattern against either firstName or lastName. The "i" flag makes the match case-insensitive, so thor finds Thor. The $or operator means a document matches if either field matches; without it you would only search one column. Remember to export both functions from the object the DAO returns so the routes can call them.
In the User routes, refactor the findAllUsers function so that it parses the role from the query string, and then uses the DAO to retrieve users with that particular role. If the query string has no role, fall through to the unfiltered list. A little later you will parse name the same way. Keeping both filters on one GET /api/users route avoids inventing a new URL for every search; the client encodes the predicate as ?role=FACULTY or ?name=thor.
const findAllUsers = async (req, res) => {
const { role, name } = req.query;
if (role) {
const users = await dao.findUsersByRole(role);
res.json(users);
return;
}
if (name) {
const users = await dao.findUsersByPartialName(name);
res.json(users);
return;
}
const users = await dao.findAllUsers();
res.json(users);
};In the React user interface application, add findUsersByRole in the client so that it encodes the role in the query string of the URL as shown below. Then add findUsersByPartialName which encodes a name the same way. The server can use that name to filter users by their first and last name.
export const findUsersByRole = async (role: string) => {
const response = await axios.get(`${USERS_API}?role=${role}`);
return response.data;
};
export const findUsersByPartialName = async (name: string) => {
const response = await axios.get(`${USERS_API}?name=${name}`);
return response.data;
};In the Users screen, add a dropdown that invokes a filterUsersByRole event handler function with the selected role. The function updates a role state variable and requests from the server the list of users filtered by their role. If the administrator picks All Roles, the handler clears the filter and calls fetchUsers again. Confirm that selecting various roles actually filters the users by their role. Style the select with Tailwind — a bordered control about a quarter of the width — rather than Bootstrap's form-select.
const [role, setRole] = useState("");
const filterUsersByRole = async (role: string) => {
setRole(role);
if (role) {
const users = await client.findUsersByRole(role);
setUsers(users);
} else {
fetchUsers();
}
};
// in the JSX:
<select
value={role}
onChange={(e) => filterUsersByRole(e.target.value)}
className="wd-select-role mb-2 w-1/4 rounded border border-neutral-300 px-2 py-1"
>
<option value="">All Roles</option>
<option value="STUDENT">Students</option>
<option value="TA">Assistants</option>
<option value="FACULTY">Faculty</option>
<option value="ADMIN">Administrators</option>
</select>Now practice filtering users by their first or last name. Create a new name state variable and a corresponding input field used to invoke findUsersByPartialName and update the users state variable with a subset of users that match the name. Confirm that typing a name in the input field actually filters the users by their first or last name. Note that the current implementation does not consider a combination of filtering by role and by name. Feel free to explore how you would go about filtering by both — for example by sending both query parameters and having the route apply $and on the server — but the required exercise is the two independent filters.
const [name, setName] = useState("");
const filterUsersByName = async (name: string) => {
setName(name);
if (name) {
const users = await client.findUsersByPartialName(name);
setUsers(users);
} else {
fetchUsers();
}
};
// in the JSX:
<input
className="wd-filter-by-name me-2 mb-2 w-1/4 rounded border border-neutral-300 px-2 py-1"
placeholder="Search people"
value={name}
onChange={(e) => filterUsersByName(e.target.value)}
/>6.2.6.4 Retrieving Documents by Primary Key from MongoDB with Mongoose
A common database operation is to retrieve documents by their primary key. Listing and filtering give you arrays; clicking a name should load one document so you can read every field, edit it, or delete it. The DAO function below retrieves a user document by its primary key. findById is a convenience for findOne({ _id: userId }) and is the usual Mongoose spelling when the identifier is the document's _id.
const findUserById = (userId) => model.findById(userId);
// route:
const findUserById = async (req, res) => {
const user = await dao.findUserById(req.params.userId);
res.json(user);
};
app.get("/api/users/:userId", findUserById);Make the findUserById DAO function available as a RESTful Web API as shown above. The route reads userId from the path, awaits the DAO, and responds with that one document. Confirm it in the browser by opening http://localhost:4000/api/users/ followed by an _id you copy from Compass. The user interface can then interact with the server using the findUserById client function shown below, which appends the id to USERS_API.
export const findUserById = async (id: string) => {
const response = await axios.get(`${USERS_API}/${id}`);
return response.data;
};In a new People Details component, use the client's findUserById function to retrieve the user when a faculty member clicks on the user's name. Parse a uid parameter and use it to retrieve the user by their ID when the component loads. If the uid does not exist, return null so that the component does not render on the screen. In useEffect, add uid as a dependency so that the component re-renders if you click on another user while the component is still displaying. The panel is a fixed column on the right — Tailwind fixed top-0 end-0 bottom-0 with a white background and a shadow — rather than a Bootstrap offcanvas.
"use client";
import { useEffect, useState } from "react";
import { FaUserCircle } from "react-icons/fa";
import { IoCloseSharp } from "react-icons/io5";
import * as client from "../../../account/client";
export default function PeopleDetails({
uid,
onClose,
}: {
uid: string | null;
onClose: () => void;
}) {
const [user, setUser] = useState<any>({});
const fetchUser = async () => {
if (!uid) return;
const user = await client.findUserById(uid);
setUser(user);
};
useEffect(() => {
if (uid) fetchUser();
}, [uid]);
if (!uid) return null;
return (
<div className="wd-people-details fixed top-0 end-0 bottom-0 z-20 w-full max-w-sm bg-white p-4 shadow">
<button type="button" onClick={onClose} className="wd-close-details absolute end-2 top-2">
<IoCloseSharp className="text-3xl" />
</button>
<div className="mt-2 text-center">
<FaUserCircle className="me-2 text-4xl text-neutral-500" />
</div>
<hr />
<div className="wd-name text-lg text-red-700">
{user.firstName} {user.lastName}
</div>
<b>Roles:</b> <span className="wd-roles">{user.role}</span>
<br />
<b>Login ID:</b> <span className="wd-login-id">{user.loginId}</span>
<br />
<b>Section:</b> <span className="wd-section">{user.section}</span>
<br />
<b>Total Activity:</b>{" "}
<span className="wd-total-activity">{user.totalActivity}</span>
</div>
);
}Add a close button rendered as an X at the top right that hides the component by calling onClose, which the table uses to clear showDetails and navigate attention back to the Users screen. From the Users page, pass fetchUsers to People Table so that we can update the users if we create, update, or delete users from the People Details dialog. In the table, keep showDetails and showUserId in state. Clicking a name sets both; closing the panel sets showDetails to false and calls fetchUsers so the list reflects any edit that happened while the panel was open. Confirm that clicking on the name of a user displays the user's details. Also confirm that closing People Details hides the component.
const [showDetails, setShowDetails] = useState(false);
const [showUserId, setShowUserId] = useState<string | null>(null);
// render PeopleDetails when showDetails is true
{showDetails && (
<PeopleDetails
uid={showUserId}
onClose={() => {
setShowDetails(false);
fetchUsers();
}}
/>
)}
// on the name cell:
onClick={() => {
setShowDetails(true);
setShowUserId(user._id);
}}6.2.6.5 Deleting a Document in MongoDB with Mongoose
To delete user documents from the users MongoDB collection, implement the deleteUser operation as shown below. The DAO function removes a single user document from the database based on its primary key. findByIdAndDelete is the Mongoose helper that both locates and removes the document; deleteOne({ _id: userId }) is an equivalent spelling if you prefer to stay consistent with the other one-argument filters.
const deleteUser = (userId) => model.findByIdAndDelete(userId);
app.delete("/api/users/:userId", async (req, res) => {
const status = await dao.deleteUser(req.params.userId);
res.json(status);
});The route below makes the deleteUser operation available as a RESTful Web API for integration with the user interface, which encodes the id of the user to remove as a path parameter. In the React Web app, implement a client function that integrates with that route.
export const deleteUser = async (userId: string) => {
const response = await axios.delete(`${USERS_API}/${userId}`);
return response.data;
};In the People Details component add buttons Cancel and Delete as shown below. The Delete button invokes a new deleteUser event handler function with uid, the ID of the user to delete. Pass a reference to fetchUsers as a parameter so People Details can notify People Table that a user has been removed and that the list of users must be updated. Use the client's deleteUser to remove the user, and then call onClose to hide the details component. The Cancel button just hides the details without removing any documents. Style Delete as a red Tailwind button and Cancel as a neutral one, floating to the end of the panel. Confirm that clicking the Cancel and Delete buttons actually work: Cancel leaves Compass unchanged; Delete removes the document and the table row disappears after the list refreshes.
const deleteUser = async (uid: string) => {
await client.deleteUser(uid);
onClose();
};
// in the JSX, after the activity fields:
<button
type="button"
onClick={() => deleteUser(uid)}
className="wd-delete float-end rounded bg-red-600 px-3 py-1 text-sm text-white"
>
Delete
</button>
<button
type="button"
onClick={onClose}
className="wd-cancel float-end me-2 rounded bg-neutral-200 px-3 py-1 text-sm"
>
Cancel
</button>6.2.6.6 Updating a Document in MongoDB with Mongoose
The Mongoose update function updates documents in MongoDB databases. In the User DAO, implement updateUser as shown below to update a single document by first identifying it by its primary key, and then updating the matching fields in the user parameter. The $set operator is important: without it, Mongoose would replace the entire document with the payload and you would lose fields the editor did not send. With $set, only the keys present in user change.
const updateUser = (userId, user) =>
model.updateOne({ _id: userId }, { $set: user });In the User routes, make the DAO function available as a RESTful Web API as shown below. Map a route that accepts a user's primary key as a path parameter, passes the ID and request body to the DAO function, and responds with the status. There is one extra responsibility that did not exist when the data lived in a file: the signed-in session may be a copy of the same document. If an administrator edits their own name, or if a user edits Profile and you later reuse this route, the session still holds the old firstName. After the database write, compare req.session["currentUser"]._id to the path userId. If they match, merge the updates into the session object so the next Profile request and the Account Navigation role check see the new values. The React client still holds its own copy in Account Context; Profile and Sign in already call setCurrentUser with the response when the signed-in person is the one being saved.
const updateUser = async (req, res) => {
const { userId } = req.params;
const userUpdates = req.body;
await dao.updateUser(userId, userUpdates);
const currentUser = req.session["currentUser"];
if (currentUser && currentUser._id === userId) {
req.session["currentUser"] = { ...currentUser, ...userUpdates };
}
res.json(currentUser);
};
app.put("/api/users/:userId", updateUser);In the React client application, the client function updateUser sends user updates to the server to be saved to the database. Use the credentials instance because this write should run in a signed-in session.
export const updateUser = async (user: any) => {
const response = await axiosWithCredentials.put(
`${USERS_API}/${user._id}`,
user,
);
return response.data;
};In the People Details component, add a name state variable to edit the first and last name of the user. Also add an editing state variable to toggle the input field that edits the name. Create a new saveUser function that splits the name state variable into firstName and lastName and sends an updated version of the user to the server. Also update the local user state variable, turn off editing, and close the dialog so People Table can refetch. Add pencil and check icons to turn editing on and off. Hide each icon based on the editing boolean state variable. Clicking the name of the user also turns editing on. If editing is on, hide the user's name and instead display an input field that shows the current user's firstName and lastName and edits the name state variable. Pressing the Enter key saves the updated user's details. Confirm users can be edited: change a last name, press Enter, reopen the row, and confirm Compass shows the new value.
const [name, setName] = useState("");
const [editing, setEditing] = useState(false);
const saveUser = async () => {
const [firstName, lastName] = name.split(" ");
const updatedUser = { ...user, firstName, lastName };
await client.updateUser(updatedUser);
setUser(updatedUser);
setEditing(false);
onClose();
};
// pencil when !editing, check when editing
// name text when !editing; input when editing
// input onKeyDown: if Enter, saveUser()6.2.6.7 Creating New Documents in MongoDB with Mongoose
In the User DAO, implement the createUser function as shown below to insert a new user object into the users collection. Spread the incoming object, assign a fresh uuidv4() identifier, and call model.create. Make sure the incoming user object does not keep a leftover _id property since it can interfere with the database insert operation — a client that accidentally posts an empty string or a copied id would collide with an existing document. Strip that property if present, then assign the new uuid.
const createUser = (user) => {
const newUser = { ...user, _id: uuidv4() };
return model.create(newUser);
};In the User routes, make the DAO operation available as a RESTful Web API for the user interface to interact with. The new user is posted to the route in the request's body. The DAO createUser function inserts the new user into the database and returns the newly inserted user which is sent back to the user interface in the response so the screen can append it without an extra GET.
const createUser = async (req, res) => {
const user = await dao.createUser(req.body);
res.json(user);
};
app.post("/api/users", createUser);In the React client application, implement a createUser client function to interact with the route created above. Post the new user object to the server as shown below.
export const createUser = async (user: any) => {
const response = await axios.post(`${USERS_API}`, user);
return response.data;
};In the Users screen, implement a new createUser event handler that sends a new user object to be inserted in the database. Use default values for the fields as shown and confirm that clicking the new + Users button actually creates the new user. The username includes Date.now() so two clicks in a row do not violate the unique username constraint. Optionally implement editing those fields in the People Details component so the administrator can replace New User with a real name after the insert.
const createUser = async () => {
const user = await client.createUser({
firstName: "New",
lastName: `User ${users.length + 1}`,
username: `newuser${Date.now()}`,
password: "password123",
email: `email${users.length + 1}@neu.edu`,
section: "S101",
role: "STUDENT",
});
setUsers([...users, user]);
};The LiveDemo below is that Users screen against the Lab 6 store — find all, filter by role and name, open details, update, delete, create. Work through each verb once so the same sequence feels familiar when you point the sibling server at Mongo and click the real Account Users link.
Users
| Name | Role |
|---|
6.3 Integrating with MongoDB Hosted in Atlas Cloud Service
SlidesWhen you run your server on your development environment, it should be connecting to a MongoDB instance running on the same local development computer. When you deploy the server on a remote server such as Render, Heroku, or AWS, the address 127.0.0.1:27017 is the virtual machine itself — empty, with no kambaz database and no Compass documents. The remote Express process needs to connect to a database that is also hosted on a public site. MongoDB Atlas Cloud Service provides a hosted database service where MongoDB instances run on public servers, and they provide a connection string to integrate our Node.js application. This section describes setting up and deploying the database online and then integrating with it from our Node.js server running on Render. You will create a free cluster, connect Compass to it so you can import the same JSON files, open Network Access so Render's IPs can reach the cluster, copy a Node.js connection string that includes the /kambaz path, and store that string as DATABASE_CONNECTION_STRING on a new Render service so you do not overwrite the Chapter 5 assignment while TAs are still grading.
6.3.1 Setting up MongoDB Atlas
To get started, head over to mongodb.com and click on Sign in at the top right corner. Login with your Google account or click on Sign Up to create an account with an email and password. If you get a validation email, confirm it and login. Answer any general questions if asked during the sign up process. In the Deploy your cluster screen choose a Free plan for now which should be enough for this course. Name your cluster Kambaz. In the Provider section, choose any of the cloud providers and in the Region section choose a region close to your geographic area, for instance AWS and North Virginia, and then click Create Deployment. In the Connect to Kambaz screen, in the Create a database user section, create credentials to login to your database. In the Username and Password fields, type credentials you will remember later since these are the credentials Mongoose will use to login to the database from your Node.js server application when running on Render or Heroku. If you forget these credentials you will need to create new ones later. Jose's example was giuseppi and a password you should not commit to GitHub or paste into the book. Click Create Database User and keep those values in a password manager; the connection string you copy next will embed them.
6.3.1.1 Connecting to a Remote Database from Compass
SlidesThen click the Choose a connection method button, and in Access your data through tools, select Compass. In the Connecting with MongoDB Compass screen, in the Copy the connection string, then open MongoDB Compass section, copy the connection string which should look something like the sample below. The host will differ; the important pieces are the mongodb+srv:// scheme, your database username, the password, and the cluster hostname Atlas assigned.
mongodb+srv://giuseppi:supersecretpassword@kambaz.jxui0bc.mongodb.net/Click Done. From Compass select Connect, New Window, paste the connection string in the URI field, and then click Connect. Compass is now talking to the cloud cluster, not to 127.0.0.1. Following the same steps used earlier in §6.1.4, create a kambaz database if Atlas did not already create one, then import the courses, modules, users, assignments, and enrollments JSON files into the remote database. Confirm the document counts match what you see on localhost. You now have two Compass connections: one local for development, one remote for the deployed server. Edits you make against localhost do not appear on Atlas until you import or until your Node app writes through the Atlas URI.
6.3.1.2 Connecting from Node.js
SlidesIn the Atlas main window click on Network Access and in the Network Access screen IP Access List tab, select + ADD IP ADDRESS. In the Add IP Access List Entry dialog click on ALLOW ACCESS FROM ANYWHERE. This will add 0.0.0.0/0 to the Access List Entry, allowing any computer to connect — including the changing outbound IPs of a free Render service. Click Confirm and verify the new entry appears in the Network Access screen. Without this entry, Mongoose on Render will hang or refuse the connection even if the password is correct. Click Database on the left and in the Clusters screen, click Connect. In the Connect to Kambaz dialog, in Connect to your application, select Drivers and version section, confirm the Driver is set to Node.js and Version is set to 5.5 or later. In the Add your connection string into your application code section, copy the URL. It should look similar to the following.
mongodb+srv://giuseppi:<password>@kambaz.jxui0bc.mongodb.net/kambaz?retryWrites=true&w=majority&appName=KambazNote: the DATABASE_CONNECTION_STRING environment variable must include the name of the database at the end of the path, for example between the last slash (/) and the question mark (?). The sample above highlights kambaz in that position. If you omit the path, Mongoose may connect to the cluster but write to the default test database instead of the collections you imported, and Dashboard will look empty even though Compass shows documents under kambaz. Replace <password> with the actual password created in an earlier step. Do not commit that completed URI.
Commit and push your code to a branch called a6 and deploy the server application to a new remote service running on Render, Heroku, or AWS. Make sure not to deploy to the server for prior chapters since TAs might still be grading it. Do not overwrite the Chapter 5 a5 Render URL. In the new remote server, configure an environment variable called DATABASE_CONNECTION_STRING with the URL value above. For instance, in the Render dashboard click on Environment, type DATABASE_CONNECTION_STRING in the Key field and the URL in the Value field. Commit and deploy the React application to a new a6 branch on Vercel. Configure NEXT_PUBLIC_HTTP_SERVER to point to the new remote Express origin, with no trailing slash. For the environment variables to take effect, you might need to redeploy and/or restart the remote Node server on Render as well as the remote React application on Vercel.
6.3.2 Configuring Session in Remote Servers
SlidesThe local Node server was configured to support multiple sessions. Similarly, the remote server also needs to be configured to support sessions so Sign in still stores currentUser in a cookie that the Vercel app can send back. In Render.com, navigate to the Environment section of the application dashboard. Click Add Environment Variable, type the name of the variables in the Key column and the variable's value in the Value column. Repeat for each of the environment variables as shown below. When environment variables change, the server must be restarted by clicking Manual Deploy and then Deploy latest commit. Below is an example of the environment variables used to configure a remote server running on Render.com. Use the same Environment Variable keys shown on the left. Do not use the sample values on the right. Instead use the values for your Mongo database, your Vercel remote server, and your remote Node server. Note: SERVER_URL should not start with https://; remove it if present.
DATABASE_CONNECTION_STRING=mongodb+srv://USER:PASSWORD@cluster/kambaz?retryWrites=true&w=majority
CLIENT_URL=https://your-a6-preview.vercel.app
SERVER_URL=your-webdev-server.onrender.com
SERVER_ENV=production
SESSION_SECRET=a long random phraseCLIENT_URL must be the Vercel origin of the a6 deployment so CORS and the session cookie sameSite settings you configured in Chapter 5 accept the browser. SERVER_ENV=production is what turns on secure cookies. After Render finishes the manual deploy, open the Vercel a6 URL, sign in, and confirm Dashboard lists courses from Atlas. If Sign in fails with a network error, check that NEXT_PUBLIC_HTTP_SERVER on Vercel matches the new Render hostname and that you redeployed Vercel after changing it.
Check Your Understanding
SlidesCheck schemas, models, DAOs, async routes, predicates, and Atlas. The practice quiz draws 10 items. It is not part of your course grade.
6.4 Integrating the Kambaz Web Application with a Database
SlidesThe current Kambaz implementation renders various courses, modules, and assignments using JSON files. The files were wrapped into a Database data structure that first lived in the React application and then in the Node server. It is time for the data to live where it belongs. This section demonstrates migrating the courses and modules into corresponding collections in the kambaz MongoDB database. Create the Mongoose schemas, models, and DAOs, and refactor the RESTful Web APIs to CRUD courses and modules in the database. The URLs stay the same as in Chapter 5; only the source of the documents changes. Confirm that all courses and modules CRUD functionality works as expected. Optionally also migrate the assignments into a collection and confirm that all assignment CRUD operations work. The signed-in user still lives in Account Context; course lists still live in Zustand. Neither client store replaces the database — they cache what the routes return.
6.4.1 Storing Courses in a Database
SlidesThe current server application implements RESTful Web APIs to access courses stored in the courses.js file. This section replaces the source of the courses with a MongoDB courses collection. The basic operations on any data source are create, read, update, and delete, colloquially referred to as CRUD operations. The following sections demonstrate how to implement those CRUD operations for courses, one verb at a time, so you can confirm each change in Compass before you move on.
6.4.1.1 Retrieving Courses from a Database
To access the courses collection created earlier, create a Mongoose schema file as shown below. The schema file describes the constraints of the documents stored in a collection, such as the names of the properties, their data types, and the name of the collection where the documents will be stored. _id stays a string for the same reason the user schema did: enrollments and modules already store those identifiers as strings. name, number, and description are strings; credits is a number so you can sort or filter numerically later if you wish.
import mongoose from "mongoose";
const courseSchema = new mongoose.Schema({
_id: String,
name: String,
number: String,
credits: Number,
description: String,
},
{ collection: "courses" });
export default courseSchema;Using the Mongoose schema file, create a Mongoose model file as shown below. Mongoose models provide functions to interact with the collection such as find(), create(), updateOne(), and deleteOne(). The CourseModel name in the Mongoose model declares a unique name that can be used as a reference from other Mongoose schemas — enrollments will ref: "CourseModel" in §6.4.3.1.
import mongoose from "mongoose";
import schema from "./schema.js";
const model = mongoose.model("CourseModel", schema);
export default model;Using the Mongoose model, refactor the courses DAO file to interact with the courses collection. Start by refactoring the findAllCourses() and findCoursesForEnrolledUser functions to retrieve courses from the database instead of the Database file as shown below. The model.find() function returns an array containing all the course documents in the courses collection. We will deal with moving enrollments to the database later in the chapter; for now the enrolled-user helper can still filter the full course list against the in-memory enrollments array if Mongo is not yet connected.
import model from "./model.js";
function findAllCourses() {
// return Database.courses;
return model.find();
}
async function findCoursesForEnrolledUser(userId) {
const { enrollments } = db;
const courses = await model.find();
const enrolledCourses = courses.filter((course) =>
enrollments.some(
(enrollment) =>
enrollment.user === userId && enrollment.course === course._id,
),
);
return enrolledCourses;
}The functions in Mongoose models all return promises, allowing asynchronous communication with the MongoDB server. In the Courses routes file, redeclare all router functions as asynchronous by adding the async keyword in front of the router functions as shown below. Also add the await keyword in front of all asynchronous calls of the DAO functions. findAllCourses becomes model.find(). Routes that used to return dao.findAllCourses() synchronously now await it. The userId === "current" branch still reads the session the way Chapter 5 taught; only the lookup behind it is now a promise.
const findAllCourses = async (req, res) => {
const courses = await dao.findAllCourses();
res.send(courses);
};
const findCoursesForEnrolledUser = async (req, res) => {
let { userId } = req.params;
if (userId === "current") {
const currentUser = req.session["currentUser"];
if (!currentUser) {
res.sendStatus(401);
return;
}
userId = currentUser._id;
}
const courses = await dao.findCoursesForEnrolledUser(userId);
res.json(courses);
};6.4.1.2 Inserting Courses into a Database
Refactor the DAO's createCourse() function to insert new courses into the database with the Mongoose model as shown below. Spread the incoming course, assign a uuidv4() identifier, and return model.create. Comment out — or delete — the old lines that pushed onto Database.courses so you do not keep two sources of truth.
function createCourse(course) {
const newCourse = { ...course, _id: uuidv4() };
return model.create(newCourse);
}Refactor the Courses routes file by adding keywords async and await before the route and DAO functions as shown below. The create route still enrolls the current user after insert, using the enrollments DAO you already have; that enroll call will itself become a database write in §6.4.3.4. Create a new course from the Dashboard and confirm the new course appears in the database. Open Compass on the courses collection and look for the name you typed.
const createCourse = async (req, res) => {
const newCourse = await dao.createCourse(req.body);
const currentUser = req.session["currentUser"];
enrollmentsDao.enrollUserInCourse(currentUser._id, newCourse._id);
res.json(newCourse);
};
app.post("/api/courses", createCourse);6.4.1.3 Deleting Courses from the Database
Refactor the deleteCourse() DAO function to delete courses from the database by using the courses model as shown below. We will deal with moving enrollments to the database later in the chapter. Until then, if Mongo is off you can still filter enrollments out of the in-memory copy so Dashboard does not keep a dangling enrollment for a course that no longer exists. When Mongo is on, model.deleteOne removes the course document and §6.4.3.3 will remove the related enrollment documents.
function deleteCourse(courseId) {
return model.deleteOne({ _id: courseId });
}In the Courses routes file, refactor the route that deletes courses by adding keywords async and await in front of the route and DAO functions as shown below. From the Dashboard, delete a course and confirm it no longer appears in the database. Refresh Compass; the document should be gone, and a subsequent GET /api/courses should omit it.
const deleteCourse = async (req, res) => {
const { courseId } = req.params;
const status = await dao.deleteCourse(courseId);
res.send(status);
};
app.delete("/api/courses/:courseId", deleteCourse);6.4.1.4 Updating Courses in the Database
In the Courses DAO, refactor the updateCourse function to update courses in the database with the model.updateOne() function as shown below. Identify the document by _id and apply $set with the fields the Dashboard editor sent — typically name and description. The old in-memory version found the course in an array and used Object.assign; the database version lets MongoDB do that merge.
function updateCourse(courseId, courseUpdates) {
return model.updateOne({ _id: courseId }, { $set: courseUpdates });
}In the Courses routes, refactor the routing function by adding async and await keywords before the routing and DAO function calls as shown below. From the Dashboard, edit a course and confirm it updates in the database. Change a name, save, and reopen the document in Compass; the new name should already be there before you refresh the browser a second time.
const updateCourse = async (req, res) => {
const { courseId } = req.params;
const courseUpdates = req.body;
const status = await dao.updateCourse(courseId, courseUpdates);
res.send(status);
};
app.put("/api/courses/:courseId", updateCourse);6.4.2 Persisting Modules in a Database as One to Many Relations with Courses
SlidesIn Kambaz, each course contains several modules, establishing a one-to-many relationship. Currently the relationship is implemented by each module having a field that refers to the course they belong to. This section demonstrates how to use Mongoose to implement one-to-many relationships. In UML, one-to-many relationships can be illustrated as a course box connected to a modules box with a "1" on the course end and a "*" on the modules end. The courses collection is said to be on the one side of a one-to-many relation and the modules collection is said to be on the many side. The relationship describes that each course is related to many modules. It is often useful to think of the relationship as a parent-child relationship describing it as courses are the parents of many modules. Another way to think of the relationship is as an ownership relationship, as in courses have many modules.
The easiest way to implement one-to-many relationships in both relational and non-relational databases is to use foreign keys referencing related records. Each module document contains a field course (or courseId) whose value is the _id of some course document the module belongs to. That is what Chapter 5 already did in JSON, and it is the shape this book's modules collection keeps so the existing /api/courses/:courseId/modules routes stay stable.
In non-relational databases there are two additional alternatives to implement one-to-many relationships. One way is to include an array of foreign keys in the parent document that reference all the child documents. Documents in the courses collection would contain a moduleIds array that contains the values of primary keys of child module documents. The keys in that array can be used to retrieve the actual documents from the modules collection. Another alternative is to do away entirely with the collection on the many side and embed the documents in the collection on the one side. The modules collection would be removed and the documents would instead be embedded in the corresponding parent course document in a modules array. Because modules are not expected to be fetched outside a course, the original chapter embeds them on the course schema. This book also keeps a modules collection with a course field. Both shapes are valid; pick one per project and stick to it. The subsections below show the embed schema so you can read the original design, then implement the collection-plus-foreign-key DAO that matches the LiveDemo routes.
- A foreign key on the child: each module has
courseequal to the course_id(what Chapter 5 already did in JSON, and what this book's collection uses). - An array of child ids on the parent.
- Embed the child documents in the parent — a
modulesarray on the course. No separate collection.
6.4.2.1 Declaring One to Many Relationships
Since modules are not expected to be accessible outside their course, the original design embeds the module documents in a new modules property in the course schema. To demonstrate, create the schema file below that describes the data structure of module documents. The embed version does not need a course field because the parent document already is the course.
import mongoose from "mongoose";
const schema = new mongoose.Schema({
_id: String,
name: String,
description: String,
});
export default schema;In the courses schema, add a new modules field defined as an array of moduleSchema as shown below if you are following the embed design.
import mongoose from "mongoose";
import moduleSchema from "../Modules/schema.js";
const courseSchema = new mongoose.Schema({
_id: String,
name: String,
number: String,
credits: Number,
description: String,
modules: [moduleSchema],
},
{ collection: "courses" });
export default courseSchema;Note that lessons also have a one-to-many relationship with the modules they belong to. Although we could create a dedicated lessons schema file and then add it to the modules schema, we can alternatively declare the lessons schema inline within the modules schema as shown below. Creating a separate dedicated lesson schema would, in general, be a better practice, but since it is a trivial schema we can get away with declaring the lesson schema right inside the parent module schema.
import mongoose from "mongoose";
const schema = new mongoose.Schema({
_id: String,
name: String,
description: String,
lessons: [{ _id: String, name: String, description: String }],
});
export default schema;The new embed schema is not compatible with the courses you imported earlier with Compass, because those course documents do not contain a nested modules array. If you adopt embed, delete all the documents from the courses collection and import a new version that embeds the lessons into their modules and the modules into their courses. The findAllCourses and findCoursesForEnrolledUser functions in the courses DAO are only used in the Dashboard page, which only really needs the course's name and description. It would be unnecessarily expensive to include the modules and lessons from the server if they are not needed in the user interface. Refactor those functions so that they only include the name and description properties in the response, using a projection as shown below. In the Dashboard, take a look at the response from the server and confirm that the courses only contain those two properties.
function findAllCourses() {
return model.find({}, { name: 1, description: 1 });
}This book's running server keeps modules in their own collection, so the schema you actually ship can keep a course string instead of embedding. The model name CourseModel is still what enrollments will ref.
import mongoose from "mongoose";
const schema = new mongoose.Schema({
_id: String,
name: String,
description: String,
course: String,
});
export default schema;6.4.2.2 Retrieving Modules for a Course
Refactor the Modules DAO so that it uses the Mongoose model to retrieve modules for a course from the database. If you embedded modules on the course, you would findById the course and return course.modules. With a separate collection,model.find({ course: courseId }) returns every module whose foreign key matches. Both answers are an array the Modules screen can map.
function findModulesForCourse(courseId) {
return model.find({ course: courseId });
}In the Modules routes, refactor the routing function by adding async and await keywords before the routing and DAO function calls as shown below. The URL is the same nested path Chapter 5 already used.
const findModulesForCourse = async (req, res) => {
const { courseId } = req.params;
const modules = await dao.findModulesForCourse(courseId);
res.json(modules);
};
app.get("/api/courses/:courseId/modules", findModulesForCourse);6.4.2.3 Creating Modules for a Course
Refactor the Modules DAO so that it uses the Mongoose model to insert a new module into the database. The collection version assigns a uuid and calls model.create, including the course id so later finds can filter. The embed version instead updateOnes the parent course with $push: { modules: newModule } so the new child is appended to the array inside the course document. Choose one; do not do both or you will insert the same module in two places.
function createModule(module) {
const newModule = { ...module, _id: uuidv4() };
return model.create(newModule);
}async function createModule(courseId, module) {
const newModule = { ...module, _id: uuidv4() };
await courseModel.updateOne(
{ _id: courseId },
{ $push: { modules: newModule } },
);
return newModule;
}In the Modules routes, add async / await and pass the courseId from the path. Create a module from the Modules screen and confirm Compass shows the new document in modules, or the new element in the course's modules array if you embedded.
6.4.2.4 Deleting Modules
Refactor the Modules DAO so that it uses the Mongoose model to delete modules from the database. The collection version is a straightforward deleteOne by module id. The embed version must $pull the matching element out of the parent course's modules array, which means the route has to know the course id as well as the module id.
function deleteModule(moduleId) {
return model.deleteOne({ _id: moduleId });
}async function deleteModule(courseId, moduleId) {
return courseModel.updateOne(
{ _id: courseId },
{ $pull: { modules: { _id: moduleId } } },
);
}This book's client keeps the Chapter 5 URL DELETE /api/modules/:moduleId because the collection already stores the foreign key. If you embed, switch the client to encode the course id as shown in the original chapter — /api/courses/:courseId/modules/:moduleId — and pass cid from the Modules page into onRemoveModule.
6.4.2.5 Updating Modules
Refactor the Modules DAO so that it uses the Mongoose model to update modules in the database. The collection version applies $set on the module document. The embed version loads the course, finds the subdocument with course.modules.id(moduleId), assigns the new fields, and save()s the parent so Mongoose writes the nested array back.
function updateModule(moduleId, moduleUpdates) {
return model.updateOne({ _id: moduleId }, { $set: moduleUpdates });
}async function updateModule(courseId, moduleId, moduleUpdates) {
const course = await courseModel.findById(courseId);
const module = course.modules.id(moduleId);
Object.assign(module, moduleUpdates);
await course.save();
return module;
}Dashboard and the Modules screen still talk to the same client functions from Chapter 5. After the DAO swap, create and rename a module and confirm Compass updates. The LiveDemo below is that Dashboard against the book store; when your sibling server is connected to Mongo, the same Add / Update / Delete buttons write documents instead of array elements.
Dashboard
New Course
Published Courses (0)
6.4.3 Persisting Enrollments in a Database as Many to Many Relations
SlidesIn Kambaz, a user can be enrolled in several courses, and a course can have many enrollments. An enrollment establishes a relationship between a user and a course. Since there can be many enrollments where many users can be enrolled, or associated, in many courses, the enrollments relation is referred to as a many-to-many relation. Many-to-many relationships can be represented in UML as a users box connected to a courses box with asterisks on both ends, capturing the fact that many users are related to many courses.
Implementing that diagram directly is awkward in both relational and document databases, because it implies that each record in the users collection contains several references to records in the courses collection and vice versa. Keeping those two arrays in sync is error-prone: if you enroll a user by pushing a course id onto the user, you must also push the user id onto the course, and a failed second write leaves the relationship half-applied. It is often easier to understand the implementation by using an intermediate collection that captures the relationship between each record in the original collections. A new enrollments collection refactors the many-to-many relationship as two one-to-many relationships. The new collection is often referred to as a mapping table or mapping collection. The original collections users and courses no longer need to know anything about each other. Instead a record entry in the enrollments collection captures what users are enrolled in what courses by declaring fields user and course that record references to the documents that are related to each other. The following sections describe how to implement many-to-many relationships using Mongoose.
6.4.3.1 Declaring Enrollments as a Many to Many Relationship
The Courses model implemented earlier declares CourseModel as the name of the model for course documents stored in the courses collection. This name can be used to establish relationships between models and collections. Similarly the Users model declares the name of the model as UserModel for user documents stored in the users collection. In a new schema file shown below, implement a many-to-many Enrollments relationship that relates user and course documents stored in the users and courses collections, referred to by their model names CourseModel and UserModel respectively. The ref option does not store a copy of the other document; it stores the identifier and tells Mongoose which model to load when you call populate.
import mongoose from "mongoose";
const enrollmentSchema = new mongoose.Schema({
_id: String,
course: { type: String, ref: "CourseModel" },
user: { type: String, ref: "UserModel" },
grade: Number,
letterGrade: String,
enrollmentDate: Date,
status: {
type: String,
enum: ["ENROLLED", "DROPPED", "COMPLETED"],
default: "ENROLLED",
},
},
{ collection: "enrollments" });
export default enrollmentSchema;Create an Enrollments model file to CRUD enrollment documents in an enrollments collection, using the name EnrollmentModel. Then create an Enrollments DAO file that implements operations that create enrollments, delete enrollments, and filter enrollments by either a course or a user. The DAO creates enrollments with an _id of userId-courseId so the pair is unique and you can find the document again without a second query. The following sections describe each of the operations in detail.
import mongoose from "mongoose";
import schema from "./schema.js";
const model = mongoose.model("EnrollmentModel", schema);
export default model;6.4.3.2 Retrieving Courses for Enrolled Users
Enrollments establish a many-to-many relationship between users and courses. A common operation consists of finding which documents in one collection are related to documents in the other collection. For instance, given a particular user we would like to determine which courses are related to that user — which courses is a user enrolled in. The findCoursesForUser() function below retrieves the enrollment documents for a given user. Those enrollment documents contain the primary keys for the user and course documents being referenced. The populate() function tells Mongoose to use the value of the primary keys to fetch the actual document referenced by the key. populate("course") replaces the course primary key value in the enrollment document with the actual course document from the courses collection corresponding to the key's value. The enrollments.map() operation unwraps the enrollments array and returns a new array with just the course objects, which is the shape Dashboard already expects.
async function findCoursesForUser(userId) {
const enrollments = await model.find({ user: userId }).populate("course");
return enrollments.map((enrollment) => enrollment.course);
}In the Courses routes, refactor route function findCoursesForEnrolledUser to retrieve courses for a given user using the new findCoursesForUser in the Enrollments DAO as shown below. Login and confirm that the Dashboard displays the courses for the logged-in user. Also confirm that creating new courses inserts new enrollments in the database — Compass should show a new enrollments document whose user is the session user and whose course is the course you just added.
const findCoursesForEnrolledUser = async (req, res) => {
let { userId } = req.params;
if (userId === "current") {
const currentUser = req.session["currentUser"];
if (!currentUser) {
res.sendStatus(401);
return;
}
userId = currentUser._id;
}
const courses = await enrollmentsDao.findCoursesForUser(userId);
res.json(courses);
};6.4.3.3 Deleting Courses
When a course goes away, every enrollment that pointed at it would otherwise become a dangling reference: Dashboard would try to populate a course id that no longer exists. In the Enrollments DAO, implement a new unenrollAllUsersFromCourse function that removes all enrollments for a given course. deleteMany is the many-document cousin of deleteOne.
function unenrollAllUsersFromCourse(courseId) {
return model.deleteMany({ course: courseId });
}In the Courses DAO, remove all uses of the enrollments from the in-memory db since we are just going to interact with enrollments in MongoDB. deleteCourse should only delete the course document. In the Courses routes, unenroll all users when the course is deleted, then delete the course. Sign in and try removing a course. Confirm that the course is removed from the database and that all enrollments are removed for the course.
const deleteCourse = async (req, res) => {
const { courseId } = req.params;
await enrollmentsDao.unenrollAllUsersFromCourse(courseId);
const status = await dao.deleteCourse(courseId);
res.send(status);
};6.4.3.4 Enrolling / Unenrolling (On Your Own)
In a prior assignment you implemented enrolling and unenrolling users from courses. The implementation relied on manipulating arrays of courses, users, and enrollments in a "Database" data structure. Now that we moved the data to an actual database, refactor your implementation to use enrollments stored in the database. The Enrollments DAO implements enrollUserInCourse and unenrollUserFromCourse functions as shown below. The enrollUserInCourse function inserts a new enrollment document in the enrollments collection creating a relation between a user and the course they are enrolled in. The unenrollUserFromCourse function deletes an existing enrollment document from the enrollments collection, removing the relation between a user and the course they were enrolled in.
function enrollUserInCourse(userId, courseId) {
return model.create({
user: userId,
course: courseId,
_id: `${userId}-${courseId}`,
});
}
function unenrollUserFromCourse(user, course) {
return model.deleteOne({ user, course });
}In the Courses routes implement post and delete routes that create or remove an enrollment using the corresponding DAO functions. If the path uid is "current", read the session user the same way the enrolled-courses route does. If you had already implemented these functions in a prior assignment, feel free to use those functions instead or refactor at your own discretion.
const enrollUserInCourse = async (req, res) => {
let { uid, cid } = req.params;
if (uid === "current") {
uid = req.session["currentUser"]._id;
}
const status = await enrollmentsDao.enrollUserInCourse(uid, cid);
res.send(status);
};
const unenrollUserFromCourse = async (req, res) => {
let { uid, cid } = req.params;
if (uid === "current") {
uid = req.session["currentUser"]._id;
}
const status = await enrollmentsDao.unenrollUserFromCourse(uid, cid);
res.send(status);
};
app.post("/api/users/:uid/courses/:cid", enrollUserInCourse);
app.delete("/api/users/:uid/courses/:cid", unenrollUserFromCourse);Wire Dashboard enroll / unenroll to those POST and DELETE URLs. The client functions encode the primary keys of the user and the course as part of the path. If you already had implemented these functions in a prior assignment, feel free to use those functions instead or refactor at your discretion.
export const enrollIntoCourse = async (userId: string, courseId: string) => {
const response = await axiosWithCredentials.post(
`${USERS_API}/${userId}/courses/${courseId}`,
);
return response.data;
};
export const unenrollFromCourse = async (userId: string, courseId: string) => {
const response = await axiosWithCredentials.delete(
`${USERS_API}/${userId}/courses/${courseId}`,
);
return response.data;
};6.4.3.5 Retrieving Students Enrolled in a Course (On Your Own)
The Users screen implemented in an earlier section uses the People Table to display all the users in the database and allows admin users to create, read, update, and delete all users in the database. The People link in the Courses Navigation in the Courses page should display a page that lists all the users enrolled in the current course, not the whole users collection. Reimplement the People link so that the People Table only displays the users that are enrolled in a particular course when navigating to the People link. In the Courses routes use the enrollments DAO findUsersForCourse function to retrieve the users enrolled in a course. That function is the mirror of findCoursesForUser: find enrollments whose course matches, populate("user"), and map to the user documents.
const findUsersForCourse = async (req, res) => {
const { cid } = req.params;
const users = await enrollmentsDao.findUsersForCourse(cid);
res.json(users);
};
app.get("/api/courses/:cid/users", findUsersForCourse);In the Courses client implement findUsersForCourse() to retrieve the users for a given course. Use the Courses routes and client to display the users enrolled in a course when navigating to a course's People route. The same People Table component from §6.2.6.2 can render the array; only the fetch changes.
export const findUsersForCourse = async (courseId: string) => {
const response = await axios.get(`${COURSES_API}/${courseId}/users`);
return response.data;
};6.4.4 Assignments (On Your Own)
Implement schema, model, DAO, routes, and client files so that the Assignments and Assignment Editor screens display assignments stored in a database. Users should be able to display assignments in a course, create new assignments, update assignments, and delete existing assignments. Confirm that all operations are reflected in the database. An assignment belongs to one course and a course has many assignments — the same one-to-many relationship you just implemented for modules. You can store assignments in their own collection with a course foreign key, which is what this book does, or embed them on the course the way the original chapter embedded modules. Mirror the modules DAO: findAssignmentsForCourse, createAssignment, updateAssignment, deleteAssignment. The routes already exist from Chapter 5 — make them await the model. The Assignment Editor should PUT the same fields you already edit in the form: title, description, points, and dates. After each save, open Compass and confirm the document changed.
6.5 Deliverables
As a deliverable, make sure you complete all the lab exercises, Mongoose schemas, models, DAOs, React components, and that they behave as described. For both the React and Node repositories, all your work should be done in a branch called a6. When done, add, commit, and push both branches to their respective GitHub repositories. Deploy the new branches to Vercel and Render (or Heroku) and confirm they integrate. If you are using Render, it does not create a separate branch deployment like Vercel, so you will have to deploy to an entirely different Web service so that you do not trample the previous assignment while the TAs are still grading. Do not overwrite the Chapter 5 a5 API URL. The Node server application running on Render will need to be configured to interact with the a6 Vercel branch deployment and with the Atlas cluster from §6.3. All the exercises should work remotely just as well as locally. The Kambaz Dashboard should display the courses and modules from the database. As a deliverable in Canvas, submit the URL to the a6 branch deployment of your React application running on Vercel.
# in webdev-client
git checkout -b a6
git add .
git commit -am "a6 MongoDB"
git push -u origin a6
# in webdev-server
git checkout -b a6
git add .
git commit -am "a6 MongoDB"
git push -u origin a6After both branches are on GitHub, work through this checklist before you submit. Graders will open the Vercel URL you turn in, sign in, and confirm the data they see is coming from Atlas through the new Render service — not from the in-memory arrays of the previous assignment.
- Finish every Lab 6 exercise on /labs/lab6 and confirm the LiveDemos in this chapter still behave as described.
- Implement the Mongoose schemas, models, and DAOs for users, courses, modules, enrollments, and (on your own) assignments. Refactor the Express routes to
async/awaitthose DAO functions. Confirm Sign in, Signup, Profile, the ADMIN Users screen, Dashboard course CRUD, module CRUD, and enroll / unenroll all persist in Compass. - Labs TOC still lists every lab, your full name,
wd-githubto the Next.js repo, plus links to the Node GitHub repo and the new Render (or Heroku) root URL. - Create a free MongoDB Atlas cluster named
Kambaz, allow access from anywhere (0.0.0.0/0), and import the Kambaz JSON files into the remotekambazdatabase. - Deploy a new Render (or Heroku) service from the Node
a6branch. On Render, setDATABASE_CONNECTION_STRINGto the Atlas URI withkambazin the path (§6.3.1.2) and the session env vars from §6.3.2:CLIENT_URL,SERVER_URL,SERVER_ENV=production, andSESSION_SECRET. Manual Deploy after you change environment variables. - Deploy the Next.js
a6branch to Vercel. SetNEXT_PUBLIC_HTTP_SERVERto the new Express origin with no trailing slash, then redeploy so the public env var is baked into the client. Disable Vercel Deployment Protection so graders can open thea6preview without signing in (§1.6). - Confirm the remote app: sign in, open Dashboard, and verify courses and modules come from Atlas. Create, edit, and delete a course and a module; enroll and unenroll; open Users as an ADMIN. Refresh Compass on the Atlas connection and confirm the documents changed.
- In Canvas, submit the Vercel URL for the
a6branch deployment.
Continue in Labs, browse Lab 6 steps, or open Kambaz.
6.6 References
This chapter stored the arrays from Chapter 5 in MongoDB. The linked terms are the database products and libraries you installed; the topics are the modeling and CRUD ideas the DAOs implemented.
These ideas also matter in this chapter even though they do not have their own term pages yet:
- Relational versus document databases
- Documents, collections, and connection strings
- Mongoose schemas, models, and DAOs
- Retrieving, creating, updating, and deleting documents
- One-to-many modules and many-to-many enrollments
- Session configuration on a remote server
6.7 Tools
Download the local database and Compass from MongoDB, then create an Atlas cluster for the hosted URI. Mongoose is the library the Node server uses; Render and Vercel still host the two deployed apps.
- MongoDB — The document database this chapter stores Kambaz collections in.
- MongoDB Community Server — The installer for a local MongoDB instance you run on your own machine.
- MongoDB Compass — A desktop GUI for browsing collections, writing queries, and inspecting documents.
- MongoDB Atlas — MongoDB's hosted cluster; you copy a connection string into the server environment.
- Mongoose — The Node.js library that defines schemas and talks to MongoDB from Express.
- Node.js — The JavaScript runtime you install so Next.js, npm, and later the Express server can run on your machine.
- Express — The Node.js HTTP framework the sibling webdev-server uses for REST routes.
- Render — The host for the Node/Express API so the deployed client has a public server URL.
- Vercel — The host for the Next.js client; connect the GitHub repo here to put the UI on the public Web.
- GitHub — The host for your remote Git repository and the place Vercel and Render connect when you deploy.
6.8 AI Tools
Schemas and predicates are easier to draft with a coding assistant, and Compass can turn a plain-language question into a filter. Read every generated query before you run it against a collection you care about.
- Cursor — An AI-native editor that reads your project and helps write or refactor TypeScript in place.
- Claude — A conversational assistant for explaining APIs, reviewing code, and drafting implementations.
- GitHub Copilot — An AI pair programmer that suggests code as you type in the editor.
- Google Prompt Gallery — A public collection of Gemini prompt examples you can remix for writing, coding, and multimodal tasks.
- Compass natural-language queries — Turns a plain-language question into a MongoDB filter so you can explore collections without writing every query by hand.