Go back

Querying a MongoDB collection

A while back, I included a views-counter component on my blog and I wrote about it here.

However, I also needed a way to return the total views of all the articles on my blog and render each view count to an article in the blog card UI.

Since MongoDB powers this feature on my blog, I went to their documentation and found out that what I wanted to do — which involved returning a couple of JSON data from the database — can be accomplished by using the find() method on a collection.

I have a Post model already in my codebase. importing my db function and using it in a Next.js API route looks a bit similar to this.

pages/api/views/index.js
import db from '@utils/mongo/model'
 
export default async function allPostViews(req, res) {
  const data = await db.Post.find()
 
  console.log(data)
}

Removing unwanted JSON objects

Initially, when I set out to build the views-count component you see when you read any article on this blog, I structured the Post schema like so:

    const postSchema = new mongoose.Schema({
      title: string;
      views: Number;
    })

I kept using this schema for a while, so whenever you visit an article’s page, say this one, for example, the data becomes this:

{
  "title": "Querying a MongoDB collection",
  "views": 30
}

But, I went on to modify the postSchema oblivious of how it’ll affect the structure of data in the db. The schema became something like this, with the slug property highlighted in line 2.

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

So, when I logged the data to the console, I got a stream of JSON matching both the former and current post schemas.

;[
  {
    title: 'Querying a MongoDB collection',
    views: 30,
  },
  {
    slug: 'querying-a-mongodb-collection',
    title: 'Querying a MongoDB collection',
    views: 30,
  },
]

That alone became chaotic for me.

Filtering the data

The current post schema has a slug property and it was what I needed exactly. So I used the filter method to return objects with the slug property.

pages/api/views/index.js
import db from '@utils/mongo/model'
 
export default async function allPostViews(req, res) {
  try {
    const data = await db.Post.find()
    const VIEWS_WITH_SLUGS = data?.filter((post) => post.slug)
 
    res.status(200).json(VIEWS_WITH_SLUGS)
  } catch (error) {
    console.error(error)
    res.status(500).json({ error: 'Internal Server Error' })
  }
}

I ran into another problem yet again. I was having duplicates of JSON objects with different view counts. All of this is my fault by the way. Something similar to the snippet below

;[
  {
    slug: 'querying-a-mongodb-collection',
    title: 'Querying a MongoDB collection',
    views: 30,
  },
  {
    slug: 'querying-a-mongodb-collection',
    title: 'Querying a MongoDB collection',
    views: 2,
  },
]

I had to look for a way to locate the index of the duplicated object by comparing their slugs, filter it from the VIEWS_WITH_SLUGS array, and return it in a new array, uniqueViews

pages/api/views/index.js
import db from '@utils/mongo/model'
 
export default async function allPostViews(req, res) {
  try {
    const data = await db.Post.find()
    const VIEWS_WITH_SLUGS = data?.filter((post) => post.slug)
 
    const uniqueViews = VIEWS_WITH_SLUGS?.filter((post, index, arr) => {
      return arr?.findIndex((p) => p?.slug === post?.slug) === index
    })
 
    res.status(200).json(uniqueViews)
  } catch (error) {
    console.error(error)
    res.status(500).json({ error: 'Internal Server Error' })
  }
}

I used the findIndex method here to check if the index of the current post object matches the first index of an object with the same slug value in the array. If it does, it means the current object is unique and will be included in the new array.

Interfacing with the API route

With that out of the way, I created a custom data hook with SWR to obtain the JSON response.

src/hooks/usePostViews.js
import useSWR from 'swr'
 
export const usePostViews = (slug) => {
  const getAllViews = async () => {
    const res = await fetch(`/api/views`)
    const data = await res.json()
 
    return data ?? []
  }
 
  const { data, loading } = useSWR(`/api/views`, () => getAllViews(), {
    revalidateOnFocus: false,
  })
 
  const post = data?.find((item) => item?.slug === slug)
  const views = post?.views?.toString()?.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
 
  const totalPostViews = data
    ?.map(({ views }) => views)
    ?.reduce((a, b) => a + b, 0)
    ?.toString()
    ?.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
 
  return {
    views,
    loading,
    data: post,
    totalPostViews,
    count: data?.length,
  }
}

Lines 11 through 13 shows how I’m using the swr hook to listen to the getAllViews() callback function. revalidateOnFocus: false ensures that the current view count isn’t incremented again in the db when you leave the tab and come back to it. PS: Not close it.

The hook expects a slug argument and lines 15 and 16 depend on that argument. Line 15 uses the array.find() method to check if the supplied argument matches any object in the list of articles on my blog.

I go on to use the post variable in line 16 to format numbers greater than or equal to 1000, so they become 1,000. That’s what the regex I got from ChatGPT does, coupled with the string.replace() method of JavaScript.

Lines 18 through 22 sums all the values gotten from the views property with ?.map(({views}) => views). The reduce method sets the default value of the accumulator, a to 0, and then proceeds to add the current value b. In each iteration, the value of the accumulator changes.

Using the hook in the component

To use the hook I imported it from the hooks directory and used it like so:

src/components/blogLayout/index.js
import { usePostViews } from 'src/hooks'
 
const BlogPostLayout = ({
      data: { id, tags, slug, title, cover_image, readingTime, publishedAt },
    }) => {
      const { views } = usePostViews(slug)
 
      const viewsText =
        views === undefined ? (
          <div
            style={{
              width: '70px',
              height: '10px',
              borderRadius: '6px',
              background: 'var(--dark-charcoal)',
            }}
          ></div>
        ) : (
          `${views} view${views > 1 ? 's' : ''}`
        )
 
      return (
        <>
          <Link href={`/blog/${slug}`}>
            <Card>
              <div className="featured" key={id}>
                <div className="card-header">
                  <p className="date">
                    {dayjs(publishedAt).format('MMMM D, YYYY')} &mdash;{' '}
                    {readingTime}
                  </p>
                  <p className="views">{viewsText}</p>
                </div>
              // rest of the markup
            </Card>
          </Link>
        </>
      )
    }

Highlighted in lines 10 through 17 is a tiny UX improvement I made to this UI component. Which was to add a visual cue, a skeleton that is rendered instead of “undefined” when the count isn’t ready/available yet.

Drawbacks

All of this wasn’t possible while I hosted on Netlify. The API routes would return the views properly in dev mode, but when I push to Prod, the serverless functions responsible for API routes with the next-runtime of Netlify would time out.

Error 502.

Sometimes, I’d get an error indicating that i.find() is not a function. Whenever I check the stack trace, it points me to the API route in /pages/api/views. Originally, I thought the error was from MongoDB, but it isn’t. I was wrong.

I had to move my deployment and update my DNS records from Netlify to Vercel’s as none of those errors were prevalent.

The last issue I encountered was somewhat related to pnpm and the Netlify next-runtime. Although, the PR that should fix the issue was merged, the issue still persists. You can take a look at a similar issue someone raised here.