TechnologySeptember 20, 2024

How to Build a Notion Clone with Astra DB and Mongoose

How to Build a Notion Clone with Astra DB and Mongoose

Editor’s note: Mongoose is a powerful and flexible ODM (Object-Document Mapping) library originally designed for MongoDB, now also compatible with Astra DB. We’re excited to publish this insightful blog post from Valeri Karpov, the creator of Mongoose, as he demonstrates how to build a Notion clone using Astra DB and Mongoose. Valeri's expertise and contributions to the developer community make this a must-read for anyone looking to enhance their full-stack development skills.

 

Notion-clone is a simplified open-source clone of Notion, a popular note-taking app. Notion-clone uses Mongoose to talk to MongoDB, which means you can also make notion-clone run on Astra DB using stargate-mongoose. This post will show how to adapt notion-clone to use stargate-mongoose (spoiler alert: it only takes about five lines of code) and how to adapt notion-clone to deploy to Vercel. Here's the full source code on GitHub in case you want to just dive into the code, or you can try out a live example on Vercel.

Making notion-clone run on Astra DB

stargate-mongoose uses Mongoose's driver API to make Mongoose talk to Astra DB. That means you just need to configure Mongoose to use stargate-mongoose, you don't need to change any existing Mongoose-based business logic. First, you need to upgrade notion-clone's Mongoose version from 5.10 to 8.3, because stargate-mongoose requires Mongoose 7.5 or higher.

{
  "dependencies": {
    "mongoose": "^8.3"
  }
}

Next, install stargate-mongoose:

npm install stargate-mongoose@0.5.5

The only code changes required are in backend/app.js: you need to set the Mongoose driver and change the Mongoose connection options as follows.

const stargateMongoose = require("stargate-mongoose");
mongoose.setDriver(stargateMongoose.driver);

mongoose.connect(process.env.ASTRA_CONNECTION_STRING, {
  isAstra: true
});

In order to run this app, you also need to set up a .env file as follows; you’ll also need to set up an Astra DB account and an Astra DB database, if you don't already have one.

FRONTEND_URL="http://localhost:3000"
DOMAIN="localhost"
JWT_KEY="yourSecretForTokenGeneration"
PORT=8080
ASTRA_CONNECTION_STRING=<your Astra connection string here>

Notion-clone API on Next.js

As the original notion-clone repo is architected, you can't deploy it only using Vercel. Vercel lets you deploy full-stack applications, including backend integration using Next.js' getServerSideProps() and API routes. The backend folder contains an Express.js backend that you can't deploy to Vercel. However, the frontend folder already uses getServerSideProps(), which means that notion-clone effectively has two separate backends.

To deploy notion-clone to Vercel, you need to port all the backend folder logic into the frontend folder, specifically into frontend/pages/api and the individual pages' getServerSideProps() function.

For example, the existing backend logic for loading a user's account information is an Express route:

// GET /users/account
router.get("/account", isAuth, usersController.getUser);

The Express route calls usersController.getUser(), which contains the following code:

const getUser = async (req, res, next) => {
  const userId = req.userId;

  try {
    const user = await User.findById(userId);

    if (!userId || !user) {
      const err = new Error("User is not authenticated.");
      err.statusCode = 401;
      throw err;
    }

    res.status(200).json({
      message: "User successfully fetched.",
      userId: user._id.toString(),
      email: user.email,
      name: user.name,
      pages: user.pages,
    });
  } catch (err) {
    next(err);
  }
};

The Express route needs to be replaced with a file in frontend/pages/api. For example, the following frontend/pages/api/get-user.js file tells Next.js to expose a GET /api/get-user endpoint, which calls usersController.getUser(). Next.js looks at the pages/api directory to determine which API routes it should expose.

import usersController from "../../controllers/users";

export default async function handler(
  req,
  res
) {
  return await usersController.getUser(req)
    .then(data => res.json(data))
    .catch(err => res.status(500).json({ message: err.message }));
}

You also need to make a few changes to the user controller's getUser() function. As written, getUser() relies on Express' response object res to send output and Express' next() function to handle errors. Next.js' response object res is slightly different, and Next.js doesn't pass a next() function to API route handlers. To more cleanly support Next.js and better abide by best practices, getUser() should instead return the response as a POJO.

const getUser = async (req) => {
  isAuth(req);
  const userId = req.userId;

  try {
    const user = await User.findById(userId);
    if (!userId || !user) {
      const err = new Error("User is not authenticated.");
      err.statusCode = 401;
      throw err;
    }

    return {
      message: "User successfully fetched.",
      userId: user._id.toString(),
      email: user.email,
      name: user.name,
      pages: user.pages,
    };
  } catch (err) {
    throw err;
  }
};

Applying these changes to all the backend Express routes will move all the backend logic into Next.js API routes, and make it possible to deploy the notion-clone backend to Vercel. However, there's one caveat remaining: notion-clone's getServerSideProps() functions would make requests to the backend routes, so the getServerSideProps() functions need to call controller functions directly rather than make API requests.

Notion-clone Controllers with Next.js getServerSideProps

The getServerSideProps() function is a special function in Next.js pages. Next.js compiles getServerSideProps() separately so it ends up running on the server-side, not in the browser. That also means you can execute backend logic in getServerSideProps(), like making Astra requests. For example, below is the getServerSideProps() function for the pages page:

export const getServerSideProps = async (context) => {
  const { token } = cookies(context);
  const res = context.res;
  const req = context.req;

  if (!token) {
    res.writeHead(302, { Location: `/login` });
    res.end();
  }

  try {
    const response = await fetch(
      `${process.env.NEXT_PUBLIC_API}/users/account`,
      {
        method: "GET",
        credentials: "include",
        // Forward the authentication cookie to the backend
        headers: {
          "Content-Type": "application/json",
          Cookie: req ? req.headers.cookie : undefined,
        },
      }
    );
    const data = await response.json();
    return {
      props: { user: { name: data.name, email: data.email } },
    };
  } catch (err) {
    return { props: {} };
  }
};

However, with Next.js, there's no reason for the extra fetch() request. You can just import the controller logic using import { getUser } from "../controllers/users" and call the controller function directly. Since getServerSideProps() runs on the backend, the controller functions can make database requests to Astra directly.

export const getServerSideProps = async (context) => {
  const { token } = cookies(context);
  const res = context.res;
  const req = context.req;
  if (!token) {
    res.writeHead(302, { Location: `/login` });
    res.end();
  }

  try {
    const data = await getUser({ ...req, cookies: { token } });
    return {
      props: { user: { name: data.name, email: data.email } },
    };
  } catch (err) {
    return { props: {} };
  }
};

The only reason why the above code works is because of the refactoring the controller-layer logic to not depend on res. Unfortunately, the response object res in getServerSideProps() is not compatible with the response object res in API routes. Because the controller-layer logic now returns the response data, getServerSideProps() and API route handlers can handle sending the response using their respective APIs.

Moving on

Adapting an existing Mongoose codebase to store data in Astra via stargate-mongoose is trivial for many apps. Just get an Astra connection string and use stargate-mongoose with mongoose.setDriver(require('stargate-mongoose).driver). If you're using Next.js, you can then deploy your Astra DB-backed full stack app to Vercel for free. Next time you're working on a Next.js and Mongoose app, try Astra DB and stargate-mongoose!

This post first appeared here.

Discover more
Mongoose
Share

One-stop Data API for Production GenAI

Astra DB gives JavaScript developers a complete data API and out-of-the-box integrations that make it easier to build production RAG apps with high relevancy and low latency.