Epic Reactions with React, MongoDB, and Next.JS

Cole Turner
Cole Turner
4 min read
Cole Turner
Epic Reactions with React, MongoDB, and Next.JS
Blog
 
LIKE
 
LOVE
 
WOW
 
LOL

Reactions are an epic way to add engagement to your blog posts. Comment systems are difficult to moderate, and I wanted to add a lever for visitors to easily pull to share how they feel about my tutorials and software engineer tips.

When I went to look for a solution online, all I found were third party integrations that were tracking users, and getting flagged by ad blockers. So I decided to roll my own epic reactions using React, MongoDB, and Next.JS

Demo of epic reactions

My JAMStack website is statically generated with Next.JS and deployed with Vercel. These reaction icons have live counters below, which are updated with the real counts when the page is refreshed.

When the user clicks one of the Reaction buttons, their browser makes a request to our Next.JS API route. That function makes a connection to our MongoDB cluster and updates a document that is associated with the post.

And the best part: it's free. No sketchy third-party integrations. That's epic!

We want to define our Reactions and to make the integration more secure. First, we will define a new file to configure what the available Reactions are:

export const REACTIONS = {
  LIKE: '😃',
  LOVE: '😍',
  WOW: '😲',
  LOL: '😂',
};

You can name the keys whatever makes sense or replace the emojis with your favorites.

The first step is to create two API routes in our Next.JS Application: one to fetch the current counts, and another to add a reaction.

Fetch current reactions by Post ID.

// pages/api/posts/[id]/reactions.js

import { REACTIONS } from '../../../../lib/constants';

export default async function getPostReations(req, res) {
  const {
    query: { id: postId },
  } = req;

  try {
    // Here is where we will integrate with MongoDB database
  
    res.statusCode = 200;
    res.setHeader('Content-Type', 'application/json');
    res.end(JSON.stringify({}));
  } catch (e) {
    console.error(e);

    res.statusCode = 500;
    res.setHeader('Content-Type', 'application/json');
    res.end(JSON.stringify({}));
  }
}

Add a Reaction via a Post ID

// pages/api/posts/[id]/addReaction.js

import { REACTIONS } from '../../../../lib/constants';

export default async function addPostReaction(req, res) {
  const {
    query: { id: postId },
  } = req;

  const clientReactions = req.body;

  try {
    // Integrate with MongoDB
    
    res.statusCode = 200;
    res.setHeader('Content-Type', 'application/json');
    res.end(JSON.stringify(postDoc?.reactions || {}));
  } catch (e) {
    console.error('Error saving reactions', e);

    res.statusCode = 500;
    res.setHeader('Content-Type', 'application/json');
    res.end(JSON.stringify({}));
  }
}

To save our epic reactions and retrieve them later, we will use MongoDB. It's fast, free, and that's awesome. You can follow the video or continue with the steps outlined below.

  1. Create a free Cloud Atlas account.
  2. Create a new Project and then Add a new Cluster.
  3. Choose your cluster plan (the shared plan is free!)
  4. Complete the form and your clusters will start spawning.
  5. Under the "Database Access" menu, choose "Add New Database User"
  6. Create a new database user, with password authentication, and read/write permissions. Save these credentials, we will use them later!
  7. Under the "Network Access" menu, choose "Add IP Address" and in the entry form, enter "0.0.0.0/0" to enable any IP address to connect.

Now we're ready to wire up our API routes to our database!

iThe first step for connecting the API routes is to establish a connection with our cluster and database.

// Connect to MongoDB
// mongodb+srv://www:<password>@epic.f2gha.mongodb.net?retryWrites=true&w=majority
const uri = `mongodb+srv://www:hunter2@cluster.url.mongodb.net?retryWrites=true&w=majority`;
const client = new MongoClient(uri);
await client.connect();

const database = client.db('epic'); // your database name from MongoDB

From here, we can then fetch or mutate the data to retrieve and add reactions.

// Our post ID
const postId = req.query.id;

// Narrow into our posts dataset
const posts = database.collection('posts');

// Find our post, only return "reactions" property
// No need to create a post object if it doesn't already exist.
const postDoc = await posts.findOne({ postId }, { reactions: true });

// Destructure from the post document, safely
const { reactions = {} } = postDoc || {};

// Filter the reactions data by our configuration
// So that we don't return deprecated reactions
// or otherwise malformed data.
const reactionValues = Object.fromEntries(
  Object.entries(reactions).filter(([key]) =>
    Object.prototype.hasOwnProperty.call(REACTIONS, key)
  )
);

res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(reactionValues));

This code example will use our posts collection to find a document where the postId attribute matches our id route param. If the document exists, it will extract the reactions data from that document. The reactions are then validated before sending the JSON response.

const postId = req.query.id;
const inputReactions = req.body;

const posts = database.collection('posts');

// Only allow reactions that are supported
const validInputReactions = Object.entries(clientReactions)
  .filter(([reactionKey]) => {
    return Object.prototype.hasOwnProperty.call(REACTIONS, reactionKey);
  });
  
// Map the incremental values to mutation keys
// i.e. { LIKE: 1 } will become { "reactions.LIKE": 1 }
const mutations = Object.fromEntries(
  validReactions.map(([key, value]) => ['reactions.' + key, value])
);

// Bail early if there's nothing to update.
if (!Object.keys(mutations).length) {
  console.error('Reactions mutations was invalid.');
  
  res.statusCode = 500;
  res.setHeader('Content-Type', 'application/json');
  res.end(JSON.stringify({}));
  return;
}

// Using the postId filter, apply the mutations above using the $inc 
// atomic operator. If the document doesn't exist, upsert will
// create the document, and returnNewDocument returns it.
const postDoc = await posts.findOneAndUpdate(
  { postId },
  { $inc: { ...mutations } },
  { upsert: true, returnNewDocument: true }
);

// Respond with the updated reactions counts
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(postDoc.reactions || {}));

In the code example above, we are validating and sanitizing the input from the browser. This is to ensure that we are only saving valid data.

Then we use the $inc atomic operator to increment the values provided from the request to the reactions for that post.

And now we're ready to wire up the UI!

This is the best part - the user interface! We now get to take our API routes and wire them up to the front-end.

The first step is rendering the buttons. We are using the <button> element so that the reactions can be tab-focusable and accessible.

import { REACTIONS } from '../../lib/constants';

// REACTIONS = { LIKE: '😀', ... }

function EpicReactions() {
  return (
    <>
      {Object.entries(REACTIONS).map(([name, emoji]) => {
        return (
          <button key={name} onClick={() => {}}>
            <div className="icon">{emoji}</div>
            <div className="name">{name}</div>
          </button>
        );
      })}
    </>
  );
}

Now it's time to add our counts to the buttons.

Vercel provides a great library of hooks for fetching data, that integrates well with Next.JS. It is called SWR (stale-while-revalidate) and we're going to use it to fetch the reaction counts.

import { REACTIONS } from '../../lib/constants';

// REACTIONS = { LIKE: '😀', ... }

function EpicReactions({ postId }) {
  // Fetch the data from our API route
  const { data = {} } = useSWR(
    () => postId && `/api/posts/${postId}/reactions`,
    fetcher
  );
  
  return (
    <>
      {Object.entries(REACTIONS).map(([name, emoji]) => {
        return (
          <button key={name} onClick={() => {}}>
            <div className="icon">{emoji}</div>
            <div className="count">{data[name] || <>&nbsp;</>}</div>
            <div className="name">{name}</div>
          </button>
        );
      })}
    </>
  );
}

In the example above, we are falling back to a non-breaking space (&nbsp;) so that the layout doesn't shift while the data is fetching.

Now we're ready to add reactions by wiring up the button clicks.

When a user clicks the button, we will want to make a request to our API route (which will update the database). On the client-side, we will update the counts in real-time.

import { REACTIONS } from '../../lib/constants';

// REACTIONS = { LIKE: '😀', ... }

function EpicReactions({ postId }) {
  // Fetch the data (reactions) from our API route
  const { data = {} } = useSWR(
    () => postId && `/api/posts/${postId}/reactions`,
    fetcher
  );

  const addReaction = (event, name) => {
    // Update the client side
    mutate(
      (reactions) => ({ ...reactions, [name]: (reactions?.[name] || 0) + 1 }),
      false
    );

    // Update the server with a JSON payload (for example { LIKE: 1 })
    fetch(`/api/posts/${postId}/addReaction`, {
      method: 'POST',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        [name]: 1
      }),
    })
  };  
  
  return (
    <>
      {Object.entries(REACTIONS).map(([name, emoji]) => {
        return (
          <button key={name} onClick={() => {}}>
            <div className="icon">{emoji}</div>
            <div className="count">{data[name] || <>&nbsp;</>}</div>
            <div className="name">{name}</div>
          </button>
        );
      })}
    </>
  );
}

🎉 Now our buttons will update and post to our API route. It works!

I hope you enjoyed this tutorial on how to add some really epic reactions to your blog or website. It's a fun way to increase engagement with your content. If you enjoyed this tutorial, let me know on Twitter (@coleturner). I want to see what epic reactions you add to your website!

 
LIKE
 
LOVE
 
WOW
 
LOL

Keep reading...

Why I Don't Like Take-Home Challenges

4 min read

If you want to work in tech, there's a chance you will encounter a take-home challenge at some point in your career. A take-home challenge is a project that you will build in your free time. In this post, I explain why I don't like take-home challenges.

Standing Out LOUDER in the Technical Interview

5 min read

If you want to stand out in the technical interview, you need to demonstrate not only your technical skills but also your communication and collaboration skills. You need to think LOUDER.

See everything