Building a Fullstack Application with Next.js and MongoDB

Pieces 🌟 - Sep 28 '22 - - Dev Community

React is one of the most prominent JavaScript frameworks in web development, providing different frameworks and libraries that aid front-end development. One example of these React frameworks is Next.js.

Next.js is a React framework that allows you to build server-side rendered web applications that leverage the features provided by React technology. In addition, Next.js enables you to build full-stack applications with no configuration. You can find additional information on the features Next.js has to offer on their official website.

In this article, we’ll review building a full-stack application that integrates MongoDB and NextAuth for authentication. You’ll need a basic knowledge of Next.js and JavaScript to follow along. Also, you’ll need to have Node.js installed on your system.

Getting Started and Installing Dependencies

In this section, we’ll create a Next.js application and install the dependencies needed for this project. To create a Next.js application, run the command linked below:

npx create-next-app@latest
Enter fullscreen mode Exit fullscreen mode

Save this code

The command above will create a new Next.js application in the folder next-mongodb. Following, we install all the required dependencies by running the command below in the terminal:

yarn add @next-auth/mongodb-adapter mongodb mongoose next-auth
Enter fullscreen mode Exit fullscreen mode

Save this code

After installing the dependencies, we’ll bootstrap the application and add the styles.

Next.js Setup

To set up the app, we add the pages and style next. In the styles folder, we’ll use the files at the following link: https://github.com/MelvinManni/next-mongoose/tree/main/styles

After this, we set up the phonebook card component and pages for the application. In the next section, we’ll cover how to set up authentication for the application using NextAuth.

NextAuth

NextAuth is a library that allows you to easily add authentication to your application. It has built-in support for most authentication services like Google, Auth0, Facebook, GitHub, etc. For this tutorial, we’ll be using GitHub authentication. To get started, we create an auth folder under the api folder. Inside the newly created auth folder, we will create a new file [...nextauth].js with the code below:

import { MongoDBAdapter } from "@next-auth/mongodb-adapter";
import NextAuth from "next-auth/next";
import Github from "next-auth/providers/github";
import clientPromise from "../../../config/database/connection";

export default NextAuth({
  providers: [
    Github({
      clientId: process.env.GITHUB_CLIENT_ID,
      clientSecret: process.env.GITHUB_CLIENT_SECRET,
    }),
  ],
  secret: process.env.SECRET,
  jwt: {
    secret: process.env.SECRET,
    maxAge: 60 * 60 * 24 * 30,
  },
  session:{
    jwt: true
  },
  adapter: MongoDBAdapter(clientPromise),
});
Enter fullscreen mode Exit fullscreen mode

Save this code

The code block above will provide an authentication API for the application. We have the client ID and client secret on lines 9 and 10. The values for the fields are available from GitHub.

  1. Log in to your GitHub account.
  2. Navigate to Settings.
  3. Click on Developer settings > OAuth Apps > New OAuth App.
  4. Fill in the details for your OAuth app. Enter http://localhost:3000/api/auth/callback/github as the value for the authorization callback URL.
  5. Register your application.
  6. Find the client id. Also, you can click the “Generate a new client secret” button to create a client secret.

Following the step above, you will create environment variables using the client id and secret generated from GitHub and pass them into the NextAuth configuration.

MongoDB Integration

Since we’ll need a database for our application, we have to choose and add a database as an adapter to the authentication configuration, which allows us to save users and sessions on the database, as we can see on line 21. Therefore, we passed the MongoDBAdapter, which accepts a client connection as a promise.

import { MongoClient } from "mongodb"
import mongoose from 'mongoose';

const uri = process.env.MONGO_URI;
const options = {
  useUnifiedTopology: true,
  useNewUrlParser: true,
}

let client
let clientPromise

if (!process.env.MONGO_URI) {
  throw new Error("Please add your Mongo URI to .env.local")
}

if (process.env.NODE_ENV === "development") {
  // In development mode, use a global variable so that the value
  // is preserved across module reloads caused by HMR (Hot Module Replacement).
  if (!global._mongoClientPromise) {
    client = new MongoClient(uri, options)
    global._mongoClientPromise = client.connect()
  }
  clientPromise = global._mongoClientPromise
} else {
  // In production mode, it's best to not use a global variable.
  client = new MongoClient(uri, options)
  clientPromise = client.connect()
}


export const connectMongo = async () => mongoose.connect(uri);

// Export a module-scoped MongoClient promise. By doing this in a
// separate module, the client 
export default clientPromise
Enter fullscreen mode Exit fullscreen mode

Save this code

In the code block above, there are two different connections to MongoDB.

  1. We’ll use the clientPromise connection in MongoDBAdapter to save users and session information.
  2. We have a second connection in the connectMongo function; this is a mongoose connection to the MongoDB client. We’ll be using this everywhere else in the application API.

Line 6 from the code block above has the MONGO_URI environment variable; we can find information on how to create a MongoDB database and connection URI from the documentation.

Application API Routes

This section will look at creating the API Routes for the application. First, we’ll create a folder called phonebooks under the api folder. After that, we’ll set up our model for the phonebook collection.

import mongoose from "mongoose";

const PhonebookSchema = new mongoose.Schema({
  user: {
    type: mongoose.Types.ObjectId,
    ref: "User",
    require: [true, "Each contact must be linked to a user"],
  },
  name: {
    type: String,
    required: [true, "Please provide contact name"],
  },
  mobile: Number,
  fax: Number,
  work: Number,
});

module.exports = mongoose.models.Phonebook || mongoose.model('Phonebook', PhonebookSchema)
Enter fullscreen mode Exit fullscreen mode

Save this code

Use the code block above to create a collection schema using mongoose for the phonebook collection. We’ll also create a mongoose collection schema for the auto-created collections from NextAuth. All of the models can be found here.

Following this, we create an index.js file under the phonebooks folders with the following code:

import { getToken } from "next-auth/jwt";
import Phonebook from "../../../config/database/models/phonebook";
import Session from "../../../config/database/models/session";
import verifyUserSession from "../../../config/utils/verifyUserSession";


const getPhonebooks = async (req, res) => {
  try {
    const user = req.user;

    const phonebooks = await Phonebook.find({ user });
    res.status(200).json({
      status: "success",
      count: phonebooks.length,
      phonebooks,
    });
  } catch (error) {
    res.send(error);
  }
};

const addPhoneBook = async (req, res) => {
  try {
    const user = req.user;
    await Phonebook.create({ ...req.body, user });

    res.status(201).json({
      status: "success",
      message: "phonebook successfully created",
    });
  } catch (error) {
    res.send(error);
  }
};

export default async function handler(req, res) {
  const { method } = req;
  await verifyUserSession({
    req,
    res,
    getToken,
    Session,
  });

  switch (method) {
    case "GET":
      await getPhonebooks(req, res);
      break;

    case "POST":
      await addPhoneBook(req, res);
      break;

    default:
      break;
  }
}
Enter fullscreen mode Exit fullscreen mode

Save this code

The code above exports a function that calls another function in a switch case, with the request method passed as a condition to the switch case. The exported function will call getPhonebooks if the request method is GET. This will return all the contacts saved in the current user's phonebook. A POST request needs an object that fits the phonebook schema and creates a new contact detail on the current user's phonebook. If we check line 38, we have the verifyUserSession function, which checks the request session to list the current user and passes the user id to the req object under the user key.

import { connectMongo } from "../database/connection";

const secret = process.env.SECRET;

export default async function verifyUserSession({ req, res, getToken, Session }) {
  await connectMongo();
  const sessionToken = await getToken({ req, secret, raw: true });

  if (!sessionToken) {
    return res.status(401).json({ status: "failed", message: "please login!" });
  }

  const user = await Session.findOne({ sessionToken });

  if (!user) {
    return res.status(404).json({ status: "failed", message: "session or user does not exist" });
  }

  if (new Date(user?.expires) < new Date()) {
    return res.status(440).json({ status: "failed", message: "session expired!" });
  }

  req.user = user.userId;
}
Enter fullscreen mode Exit fullscreen mode

Save this code

Next, we’ll set up API routes for handling fetching, updating, and deleting a contact. Finally, we’ll create a new file [id].js in the phonebooks folder. This will let us pass a query id to the req object for requests made to the phonebooks API route.

import { getToken } from "next-auth/jwt";
import Phonebook from "../../../config/database/models/phonebook";
import Session from "../../../config/database/models/session";
import verifyUserSession from "../../../config/utils/verifyUserSession";

const getPhonebook = async (req, res) => {
  try {
    const phonebook = await Phonebook.findOne({
      user: req.user,
      _id: req.query.id,
    });

    res.status(200).json({
      status: "success",
      contact: phonebook,
    });
  } catch (error) {
    res.send(error);
  }
};

const updatePhonebook = async (req, res) => {
  try {
    const user = req.user;

    const docId = req.query.id;
    // Remove field we do not want to update
    req.body.user = undefined;
    req.body._id = undefined;
    await Phonebook.findOneAndUpdate({ _id: docId, user }, { ...req.body });

    res.status(201).json({
      status: "success",
      message: `phonebook with id ${docId} updated`,
    });
  } catch (error) {
    res.send(error);
  }
};

const deletePhonebook = async (req, res) => {
  try {
    const user = req.user;
    await Phonebook.findOneAndDelete({ _id: req.query.id, user });

    res.status(201).json({
      status: "success",
      message: `phonebook with id ${req.params.id} deleted`,
    });
  } catch (error) {
    res.send(error);
  }
};

export default async function handler(req, res) {
  const { method } = req;

  await verifyUserSession({
    req,
    res,
    getToken,
    Session,
  });

  switch (method) {
    case "GET":
      await getPhonebook(req, res);
      break;

    case "PATCH":
      await updatePhonebook(req, res);
      break;

    case "DELETE":
      await deletePhonebook(req, res);
      break;

    default:
      break;
  }
}
Enter fullscreen mode Exit fullscreen mode

Save this code

The getPhonebook function will return a single contact from a user's phonebook with an id matching the query id. In addition, the application will execute the updatePhonebook function for "PATCH" request methods; the function will update the current contact with an id matching the query id, and the deletePhonebook function will delete a user's contact matching the query id.

Consuming API

Following, we look at how to use the APIs created for the application. Firstly, we’ll look at handling authentication for the application's client side.

Under the pages folder, we create an index.js file with this code:

import { useSession, signIn, signOut } from "next-auth/react";
import Head from "next/head";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import PhonebookCard from "../components/PhonebookCard";
import styles from "../styles/Home.module.css";

export default function Home() {
  const [phonebooks, setPhonebooks] = useState([]);
  const { data: session } = useSession();

  const getPhonebooks = useCallback(async () => {
    if (session) {
      const res = await fetch("/api/phonebooks/");
      const data = await res.json();
      console.log(data);
      data.phonebooks && setPhonebooks(data.phonebooks);
    }
  }, [session]);

  useEffect(() => {
    getPhonebooks();
  }, [getPhonebooks]);

  return (
    <div className={styles.container}>
      <Head>
        <title>Next MongoDb</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        {!session ? (
          <button onClick={() => signIn()} className={styles.btn}>
            Sign In
          </button>
        ) : (
          <>
            <h4 className="text-center">
              Welcome, <br />
              {session?.user?.name}
            </h4>
            <button onClick={signOut}>Sign Out</button>

            <div className={styles.linkText}>
              <Link href="/add-phonebook"> Add Contact</Link>
            </div>

            <div className={styles.cardContainer}>
              <div className="cardGrid">
                {phonebooks.map((contact) => (
                  <PhonebookCard
                    key={contact._id}
                    name={contact.name}
                    fax={contact.fax}
                    mobile={contact.mobile}
                    work={contact.work}
                    id={contact._id}
                    refresh={getPhonebooks}
                  />
                ))}
              </div>
            </div>
          </>
        )}
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Save this code

On line 10, we have the session variable destructured from the useSession hook from NextAuth. The session variable holds the details of the current user session; it returns an object of the current session details or is undefined if a session does not exist. On line 34, we have a ternary operator that checks if a session exists. The operation renders a button that calls the signIn function from NextAuth if there is no active session; otherwise, it renders the current user's phonebook with all contact details. Finally, on line 12, we have the getPhonebooks callback. This handles fetching the current users and assigning the value to the phonebooks state.

Following this, we set up the page to add a new contact to a user's phonebook. Under the pages folder, we’ll create a new add-phonebook.js file:

import { useSession, signIn, signOut } from "next-auth/react";
import Head from "next/head";
import Link from "next/link";
import { useState } from "react";
import styles from "../styles/Home.module.css";
import phonebookStyles from "../styles/Phonebook.module.css";

export default function Home() {
  const { data: session } = useSession();
  const [loading, setLoading] = useState(false);
  const [state, setState] = useState({
    name: "",
    mobile: "",
    fax: "",
    work: "",
  });

  const handleChange = (e) => {
    const { name, value } = e.target;

    setState((prev) => ({ ...prev, [name]: value }));
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    setLoading(true);
    try {
      await fetch("/api/phonebooks/", {
        method: "post",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
        },
        body: JSON.stringify(state),
      });
    } catch (error) {
      console.log(error);
    } finally {
      setState({
        name: "",
        mobile: "",
        fax: "",
        work: "",
      });
      setLoading(false);
    }
  };

  return (
    <div className={styles.container}>
      <Head>
        <title>Next MongoDb</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        {!session ? (
          <button onClick={() => signIn()} className={styles.btn}>
            Sign In
          </button>
        ) : (
          <>
            <div className={styles.linkText}>
              <Link href="/"> Go Back</Link>
            </div>
            <h4 className="text-center">Add New Contact</h4>
            <button onClick={signOut}>Sign Out</button>

            <div className={styles.cardContainer}>
              <form onSubmit={handleSubmit} className={phonebookStyles.card}>
                <input onChange={handleChange} name="name" value={state.name} type="text" placeholder="Enter contact name" />
                <input onChange={handleChange} name="mobile" value={state.mobile} type="text" placeholder="Enter contact mobile no." />
                <input onChange={handleChange} name="fax" value={state.fax} type="text" placeholder="Enter contact fax no." />
                <input onChange={handleChange} name="work" value={state.work} type="text" placeholder="Enter contact work no." />
                <button disabled={loading} className={`${styles.btn} ${styles.centerBtn}`}>
                  {loading ? "Creating..." : "Create Contact"}
                </button>
              </form>
            </div>
          </>
        )}
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Save this code

The handleSubmit function above will make a POST request to the phonebooks API with the state passed as the request body. This will allow the creation of a new contact for the current user's phonebook. Under the PhonebookCard, we have a delete request to the phonebooks API with the contact ID passed as request query:

import { useRouter } from "next/router";
import React, { useState } from "react";
import styles from "../styles/Phonebook.module.css";
export default function PhonebookCard({ name, mobile, fax, work, id, refresh }) {
  const router = useRouter();
  const [loading, setLoading] = useState(false);
  const handleEdit = () => {
    router.replace("/update-phonebook/" + id);
  };
  const handleDelete = async () => {
    setLoading(true);
    try {
      await fetch("/api/phonebooks/" + id, {
        method: "delete",
      });
    } catch (error) {
      console.log(error);
    } finally {
      setLoading(false);
      refresh && refresh();
    }
  };

  return (
    <div className={styles.card}>
      <div>
        <div className={styles.textWrapper}>
          <p className={styles.title}>Name:</p>
          <p className={styles.text}>{name}</p>
        </div>
        <div className={styles.textWrapper}>
          <p className={styles.title}>Mobile:</p>
          <p className={styles.text}>{mobile || "N/A"}</p>
        </div>
        <div className={styles.textWrapper}>
          <p className={styles.title}>FAX:</p>
          <p className={styles.text}>{fax || "N/A"}</p>
        </div>
        <div className={styles.textWrapper}>
          <p className={styles.title}>Work:</p>
          <p className={styles.text}>{work || "N/A"}</p>
        </div>
      </div>

      <div className="row-between">
        <button onClick={handleEdit}>Edit</button>
        <button onClick={handleDelete}>{loading ? "Deleting..." : "Delete"}</button>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Save this code

On line 13, we have the handleDelete function. This deletes a contact matching the ID passed to the request query. The handleEdit function on line 9 will replace the current route with the update-phonebook route.

Finally, we’ll update a phonebook. To do this, we create a [id].js file under update-phonebook folder in pages:

import { useSession, signIn, signOut } from "next-auth/react";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
import { useCallback, useEffect, useState } from "react";
import styles from "../../styles/Home.module.css";
import phonebookStyles from "../../styles/Phonebook.module.css";

export default function Home({ contact }) {
  const { data: session } = useSession();
  const [loading, setLoading] = useState(false);
  const router = useRouter();
  const query = router.query;
  const [state, setState] = useState({
    name: "",
    mobile: "",
    fax: "",
    work: "",
  });

  const getContact = useCallback(async () => {
    if (session) {
      const res = await fetch("/api/phonebooks/" + query.id);
      const data = await res.json();
      data.contact && setState(data.contact);
    }
  }, [query.id, session]);

  useEffect(() => {
    getContact();
  }, [getContact]);

  const handleChange = (e) => {
    const { name, value } = e.target;
    setState((prev) => ({ ...prev, [name]: value }));
  };
  const handleSubmit = async (e) => {
    e.preventDefault();
    setLoading(true);
    try {
      await fetch("/api/phonebooks/" + query.id, {
        method: "PATCH",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
        },
        body: JSON.stringify(state),
      });
    } catch (error) {
      console.log(error);
    } finally {
      setLoading(false);
      getContact()
    }
  };
  return (
    <div className={styles.container}>
      <Head>
        <title>Next MongoDb</title>
        <meta name="description" content="Generated by create next app" />        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        {!session ? (
          <button onClick={() => signIn()} className={styles.btn}>
            Sign In
          </button>
        ) : (
          <>
            <div className={styles.linkText}>
              <Link href="/"> Go Back</Link>
            </div>
            <h4 className="text-center">Update Contact: {query.id}</h4>
            <button onClick={signOut}>Sign Out</button>

            <div className={styles.cardContainer}>
              <form onSubmit={handleSubmit} className={phonebookStyles.card}>
                <input onChange={handleChange} name="name" value={state.name || ""} type="text" placeholder="Enter contact name" />
                <input onChange={handleChange} name="mobile" value={state.mobile || ""} type="text" placeholder="Enter contact mobile no." />
                <input onChange={handleChange} name="fax" value={state.fax || ""} type="text" placeholder="Enter contact fax no." />
                <input onChange={handleChange} name="work" value={state.work || ""} type="text" placeholder="Enter contact work no." />
                <button disabled={loading} className={`${styles.btn} ${styles.centerBtn}`}>
                  {loading ? "Updating..." : "Update Contact"}
                </button>
              </form>
            </div>
          </>
        )}
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Save this code

The code block above fetches the contact detail with an ID matching the router query, allowing us to edit and persist the new values in the database. Line 21 is a callback function that fetches the contact details from the database and assigns the value to the state. Finally, the handleSubmit function updates the existing value with the new value on the database.

Signing in and using our application.

Deploying the Application

We’ve successfully set up all the application features! The next step is to deploy the application. This article covers deploying the application on Vercel from your GitHub repository.

  1. Visit https://vercel.com/ and sign up.
  2. On your dashboard, click on the Add New button > Project.
  3. On the import from Git Repository, search the repository for your Next.js application. Vercel will automatically detect the project is Next.js.
  4. Click on “Deploy.”

NOTE: You should update the URL in your GitHub OAuth app setting after deploying your application.

Conclusion and Resources

This article covered how to build a full-stack application with Next.js using MongoDB as a database. In addition, we covered how to authenticate a Next.js application using the NextAuth library. Official documentation can be found at Next.js and NextAuth. The complete code for the project can be reviewed in the GitHub Repository. Furthermore, you can test your knowledge of building a full-stack application with Next.js by creating a blog or an e-commerce store using the information from this article. Happy coding! ✨

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .