Caleb

Building a real-time views component with Next.js and MongoDB

Most of the features I keep trying to add to my blog are due to the inspiration I get from other people's work. I came across this one for the very first time on Lee Robinson's blog, and I've always wanted something like that.

I read through his approach and found out that he resorted to using firebase to update the views of each article on his blog through Next.js' API routes. I went on to try it, and I've got to say, I don't know why a particular thing that worked for Developer A never works for me. The same thing happened when I was trying to build this blog.

Although there were other alternatives I discovered from some articles on DEV, that required the use of Supabase to store the views, I wasn't comfortable with the tool, probably because I was a bit skeptical because it isn't something I'm familiar with, compared to its other counterparts like MongoDB and Firebase.

Choosing MongoDB as my database

Okay, I don't think there is a concrete reason why I chose Mongo. But, it is somewhat tied to the research I made on how I'd be able to implement this feature after taking a look at the approach that other people used, and because it is a popular database used by many backend developers who use Node.js. Luckily, MongoDB provides a Node.js driver that I can use to interface with its APIs.

There aren't so many resources around integrating a static site like mine with this tool. I had no idea about what I was doing. All I had at the back of my mind was that I want to increment the views of an article when its route is visited by anyone.

And that was the bane of my research. I remember asking a question on stackoverflow on how I'd be able to accomplish this. I think the question has been deleted or made hidden, because it is "opinion-based", and is currently not accepting answers. But, give it a look, nonetheless.

Connecting to the MongoDB cluster

Setting up the cluster that would house my database, and the collection(s) that will be there in the future, was a bit stressful. But, I stumbled upon this guide that walked me through the process.

Since I'd be needing a medium of accessing the database, and the collections in my cluster, I'll be needing the Next.js API route, to serve as an endpoint that I'll consume via aPOST request which updates the views whenever it is visited.

You know how we have dynamic routes in Next.js right? The same thing applies to API routes, since this feature I want to implement depends on the change of the slug that is appended aft the blog's URL. In the pages folder.

Creating the API endpoint

Below, you'll find the code snippet of the API endpoint, you'll notice how I'm using the square brackets [] naming convention. This is just to inform Next.js that the data from this endpoint is a dynamic one.

// api/views/[slug].js
import Post from '@utils/mongo/model'

export default (req, res) => {
  const { slug } = req.query

  // Use the mongoose model to find and update the article
  Post.findOneAndUpdate(
    { slug },
    { $inc: { views: 1 } },
    { upsert: true },

    (err, article) => {
      if (err) {
        // Handle any errors
        return handleError(res, err)
      }

      // Save the updated article to the collection
      article.save((err, updatedArticle) => {
        if (err) {
          return handleError(res, err)
        }

        // Return the updated article
        res.json(updatedArticle)
      })
    }
  )
}

function handleError(res, err) {
  return res.status(500).json({ error: err })
}

Okay, the snippet above is a bit long, But, in summary, this is what is happening there: First, I'm importing the mongoose model that I created beforehand. This is to ensure that the document in the collection has the appropriate schema and data types. In the next lines, I'm destructuring the slug parameter from the req — request object, since the slug is what I'll be sending via the POST request to the database.

From line 5 downwards, I'm using the findOneAndUpdate method of the mongoose model to increment the views of an article when its slug is found in the collection with the $inc method. The upsert property made it possible for me to save the updated article schema into the collection by using the .save() method on the mongoose model.

In between the lines, you'll notice a little error-handling process with a reusable function I declared, far down.

function handleError(res, err) {
  return res.status(500).json({ error: err })
}

Take a look at what the schema looks like, below. You'll also notice that I destructured the NEXT_PUBLIC_MONGO_URI from the process Node object. Instead of doing something like: process.env.NEXT_PUBLIC_MONGO_URI

import mongoose from 'mongoose'

const { NEXT_PUBLIC_MONGODB_URI } = process.env

mongoose.connect(NEXT_PUBLIC_MONGODB_URI, {
  useNewUrlParser: true,
  useUnifiedTopology: true,
})

const postSchema = new mongoose.Schema({
  slug: String,
  title: String,
  views: Number,
})

const Post = mongoose.model('post', postSchema)

export default Post

If you're wondering how I got the environment variable I'm using, Kindly consult this MongoDB Atlas + Next.js guide I shared previously. When you've obtained your environment variable, you can come back here and use my approach of connecting to the database like so:

mongoose.connect(NEXT_PUBLIC_MONGODB_URI, {
  useNewUrlParser: true,
  useUnifiedTopology: true,
})

Consuming the API

Using the API wasn't an issue so long as I connected to my cluster and got the API route working correctly, all I needed was to create a reusable component that returns the number of views an article gets. The snippet below shows a representation of the component.

export default function ViewCounter({ slug }) {
  const [view, setView] = React.useState(0)

  React.useEffect(() => {
    fetch(`/api/views/${slug}`, {
      method: 'POST',
      priority: 'high',
    })
      .then((response) => response.json())
      .then((data) => {
        setView(data.views)
      })
  }, [])

  let count = view.toLocaleString()

  return count > 0 ? `${count} views` : ''
}

wrapping up

At first, I tried using SWR to render the number of views, but it was flawed, for my use-case at least, because anytime I hit the views endpoint I kept on getting more than one response, making every visit to a unique slug to be incremented unnecessarily.

Other than that, my component looked a little bit similar to what you're seeing below.

async function fetcher(...args) {
  const res = await fetch(...args, {
    method: 'POST',
    priority: 'high',
  })

  return res.json()
}

export default function ViewCounter({ slug }) {
  const { data } = useSWR(`/api/views/${slug}`, fetcher, {
    revalidate: false,
  })
  const view = new Number(data?.views)

  React.useEffect(() => {
    fetch(`/api/views/${slug}`, {
      method: 'POST',
      priority: 'high',
    })
      .then((response) => response.json())
      .then((data) => {
        setView(data.views)
      })
  }, [slug])

  let count = view.toLocaleString()

  return count > 0 ? `${count} views` : ''
}

Not only was this approach taking up so much space, but it also wasn't even yielding the correct result. I even went on to add the revalidate key, in hopes that it would fix the multiple responses issue. But, it didn't, maybe I was missing something though.

When it was time for me to deploy the feature and see it live, nothing worked as I expected it to. I kept on getting this very annoying error — "Bad error, missing form" — whenever I check my networks tab in dev tools. I also noticed that the status code of my requests was 400. I was soo pissed, to the extent that I just abandoned figuring out a way to fix it for almost a week or so.

But, when I found a fix for it, I wrote about it here, kindly read it.