loading...
Cover image for Build a Like Counter With Fauna, Netlify and Nuxt

Build a Like Counter With Fauna, Netlify and Nuxt

davidtparks profile image David Parks Originally published at davidparks.dev on ・10 min read

Intro

When I was building this website, I constantly sought inspiration from some people involved in tech that I follow and look up to. One of these people is Josh Comeau (@joshwcomeau on Twitter).

On his website, I read an awesome blog titled Building a Modern-Day Hit Counter that shed some light on how powerful serverless functions in tandem with FaunaDB can be on Jamstack sites.

While his post dealt mainly with React/Gatsby, I wanted to use some of those serverless concepts and code he provided to create a "like" counter (in my case a "voltage" counter) on every blog post as a cool little detail (very similar to Josh's "heart" counter on his blog posts). I'll also be re-using and extending some of his code so I would like to give him credit!

The aim of this post is to get you setup using Netlify functions and FaunaDB, and to show you how you can leverage these tools to build a dynamic "like" counter for your personal Nuxt blog.

If you aren't familiar with Fauna, it's a low-latency serverless cloud database with a GraphQL API. We're going to be using it to store "like" counts for each of our blog posts using our blogs slug as the unique identifier.

Setup

Before we begin, we'll need to make sure that we have a Fauna account setup to handle storing and retrieving data, as well as a Netlify account where we'll be deploying our application and bundled serverless functions.

Also, you'll need to pick a headless CMS that you prefer to manage your blog content. I personally use Nuxt Content, but as long as you can leverage or define a unique slug for each blog post you will be fine!

Fauna

Once you've created a Fauna account, you'll want to login and create a new database for your project.

Screenshot showing how to create a new Fauna database

After setting up our database, we'll also need to create a collection which will store the likes for each of our blog posts. Go ahead and create one named "likes".

Screenshot showing how to create new Fauna collection

In order to successfully query our database, we'll also need to setup an Index. Indexes allow us to query documents on document attributes rather than document references, which will be important when we fetch and update our corresponding likes using the blogs slug.

Feel free to read up on Indexes over at Fauna's official documentation, as we're barely scratching the surface of how powerful they are.

Go ahead and create an index named likes_by_slug using our likes collection as the source collection, and data.slug being one of our terms which can be searched.

Screenshot showing how to create a new FaunaDB index

Amazing! We are almost there. Now we need to generate an API key that we can use in our Nuxt project to securely query our Fauna database. Navigate to the Security section in your Fauna dashboard and generate a new admin key.

Screenshot showing how to generate a FaunaDB database API key

We'll be referencing this key using the FAUNA_SECRET_KEY environment variable.

Nuxt

Great! We've got Fauna all setup. Now we can hop over to our Nuxt project and start to pull in some dependencies we'll need to get started.

Go ahead and install the Fauna npm package.

# install using npm
npm install --save faunadb

# install using yarn
yarn add faunadb
Enter fullscreen mode Exit fullscreen mode

We'll also be using Axios to handle our API requests, so go ahead and install the Nuxt module:

# install using npm
npm install @nuxtjs/axios

# install using yarn
yarn add @nuxtjs/axios
Enter fullscreen mode Exit fullscreen mode

And add it to your nuxt.config.js file:

export default {
  modules: ['@nuxtjs/axios']
}
Enter fullscreen mode Exit fullscreen mode

Also, create a .env file in your project's root and pull in your Fauna API Key that you generated earlier

BASE_URL=YOUR_PROD_URL
FAUNA_SECRET_KEY=YOUR_KEY
Enter fullscreen mode Exit fullscreen mode

Great! Lastly, we can now use our BASE_URL environment variable to get Axios setup for both local development and our production deployment.

We'll be using Nuxt's Runtime Config to handle setting our Axios Options. Add this line to your nuxt.config.js file:

export default {
  publicRuntimeConfig: {
    axios: {
      baseURL: process.env.NODE_ENV === 'production' ? process.env.BASE_URL || 'http://localhost:8888/' : 'http://localhost:8888/',
    }
  },
}
Enter fullscreen mode Exit fullscreen mode

When I run my Netlify project locally using Netlify Dev, I run it on port 8888. In order to not have issues when we try to test our serverless functions, we need to tell Axios to call via port 8888, or whatever port you'd like when in local development.

Netlify

If you aren't familiar with Netlify functions, they're essentially Netlify's wrapper on top of AWS Lambda (with a very generous free-tier). To keep it simple, they're fully managed and auto-scaling serverless functions that just work with your Netlify projects.

In order to make sure that our deployed site can successfully query our Fauna database, we'll need to navigate to Netlify and add the environment variables that we defined earlier, so they will be available to our application when we deploy it!

Screenshot showing how to generate a FaunaDB database API key

You should also check out Netlify Dev which will allow you to test your functions locally before deployment. This can save you lots of time when debugging your functions and UI. It's a bit of a process so I won't cover it, but definitely recommend getting the Netlify CLI setup and linking your site to your project!

Excellent! We are now completely done with setting up the pieces we need.

We have Fauna to handle our "likes" storage, Netlify Functions to handle updating and retrieving that data via Axios, a Headless CMS of our choice, and of course, Nuxt!

Time to dive into some code!

Writing our functions

Our needs for these functions can be broken down into two pieces.

  • We need to fetch the current likes of a blog on page load
  • We need to be able to increment these likes when a user clicks on our "like" counter

Fetching likes function

We'll start with the one that will fetch our blogs like count on page load (or component mount).

Go ahead and create a folder in the root of your Nuxt project called functions. Netlify will automatically look here for any serverless functions each time you deploy.

Create a file called fetch_likes_for_blog.js inside of the functions folder.

// Credit to Josh Comeau 
const faunadb = require('faunadb');
exports.handler = async (event) => {
  const q = faunadb.query;
  const client = new faunadb.Client({
    secret: process.env.FAUNA_SECRET_KEY,
  });

  const { slug } = event.queryStringParameters;
  if (!slug) {
    return {
      statusCode: 400,
      body: JSON.stringify({
        message: 'Article slug not provided',
      }),
    };
  }

  const doesDocExist = await client.query(
    q.Exists(q.Match(q.Index('likes_by_slug'), slug))
  );

  if (!doesDocExist) {
    await client.query(
      q.Create(q.Collection('likes'), {
        data: { slug: slug, likes: 1 },
      })
    );
  }

  const document = await client.query(
    q.Get(q.Match(q.Index('likes_by_slug'), slug))
  );

  return {
    statusCode: 200,
    body: JSON.stringify({
      likes: document.data.likes,
    }),
  };
};
Enter fullscreen mode Exit fullscreen mode

Let's run through it.

  1. First, we are initializing our Fauna client with the secret key we generated earlier
  2. We are checking if the blog slug is provided as a query parameter, if not we return 400
  3. We check if the document in Fauna already exists
  4. If it does not exist, we create a new document within our likes collection and set the initial likes to 1 using our slug as the unique identifier
  5. We query the likes_by_slug index we created earlier using our slug to retrieve the current likes
  6. We return the likes of the blog

Incrementing likes function

This function is extremely similar to the fetch function, except for the fact that we need to increment the existing like count before we return it to the client.

This is the function that we'll be calling when a user interacts with whatever icon/button we want to use to handle liking the blog post.

Create a new file in your functions directory called register-like.js

// Credit to Josh Comeau
const faunadb = require('faunadb');
exports.handler = async (event) => {
  const q = faunadb.query;
  const client = new faunadb.Client({
    secret: process.env.FAUNA_SECRET_KEY,
  });

  const { slug } = event.queryStringParameters;
  if (!slug) {
    return {
      statusCode: 400,
      body: JSON.stringify({
        message: 'Article slug not provided',
      }),
    };
  }

  const doesDocExist = await client.query(
    q.Exists(q.Match(q.Index('likes_by_slug'), slug))
  );

  if (!doesDocExist) {
    await client.query(
      q.Create(q.Collection('likes'), {
        data: { slug: slug, likes: 1 },
      })
    );
  }

  const document = await client.query(
    q.Get(q.Match(q.Index('likes_by_slug'), slug))
  );

  await client.query(
    q.Update(document.ref, {
      data: {
        likes: document.data.likes + 1,
      },
    })
  );

  return {
    statusCode: 200,
    body: JSON.stringify({
      likes: document.data.likes,
    }),
  };
};
Enter fullscreen mode Exit fullscreen mode

As you can see, the code is essentially the same except for the fact that after we query the index, we then run an update on our document and increment the likes by 1.

Using our functions in Nuxt

When we end up deploying our application, they will be available to us at the path: /.netlify/functions/NAME_OF_FUNCTION.

In my case, I created a VoltBatteryCounter component that I plug into the sides of my dynamic blog pages in pages/blog/_slug.vue. In this component, I am passing the slug to the function by utilizing this.$route.params.slug.

Since I am using Nuxt's static mode, I am leveraging the fetch hook with fetchOnServer: false set in order to ensure that the hook is called each time the component is mounted and not only once during build.

<script>
export default {
  data() {
    return {
      initialLikes: null,
    }
  },
  async fetch() {
    const { data } = await this.$axios.get(`/.netlify/functions/fetch_likes_for_blog?slug=${this.$route.params.slug}`);
    this.initialLikes = data.likes;
  },
  fetchOnServer: false,
}
</script>
Enter fullscreen mode Exit fullscreen mode

And then, whenever a user clicks on the Battery I have positioned to the right of my blog post, I will use Axios that we setup earlier to call our servleress function to increment the like count.

<script>
export default {
  data() {
    return {
      initialLikes: null,
    }
  },
  async fetch() {
    const { data } = await this.$axios.get(`/.netlify/functions/fetch_likes_for_blog?slug=${this.$route.params.slug}`);
    this.initialLikes = data.likes;
  },
  fetchOnServer: false,
  methods: {
    addLike() {
      this.initialLikes++;
      this.incrementLikes();
    },
    async incrementLikes() {
      await this.$axios.post(`/.netlify/functions/register-like?slug=${this.$route.params.slug}`);
    }
  },
}
</script>
Enter fullscreen mode Exit fullscreen mode

You might be wondering though, what if somebody decides to just spam my like button thousands of times and completely eat up my free-tier of functions?

There are a few routes you could go with this. One of which would be to introduce some local state like userLikeCount along with a computed property called likesMaxed which only allows a user to increment the counter up to 12 times or so. Ex:

<script>
export default {
  data() {
    return {
      initialLikes: null,
      userLikeCount: 0,
    }
  },
  async fetch() {
    const { data } = await this.$axios.get(`/.netlify/functions/fetch_likes_for_blog?slug=${this.$route.params.slug}`);
    this.initialLikes = data.likes;
  },
  fetchOnServer: false,
  methods: {
    addLike() {
      if(!likesMaxed) {
        this.initialLikes++;
        this.userLikeCount++;
        this.incrementLikes();
      }
    },
    async incrementLikes() {
      await this.$axios.post(`/.netlify/functions/register-like?slug=${this.$route.params.slug}`);
    }
  },
  computed: {
    likesMaxed() {
      return this.userLikeCount >= 12;
    },
  },
}
</script>
Enter fullscreen mode Exit fullscreen mode

However, the user could simply reload the page and increment the counter another 12 times.

In my case, I decided to use localStorage to ensure that users could only increment the like count a maximum of 12 times per blog post which would persist on subsequent page visits (Credit to Lucie for the idea!)

Storing Likes in Local Storage

Start by creating a file called index.js from within your store directory in your Nuxt project.

export const state = () => ({
  storedUserLikes: 1
})

export const mutations = {
  initializeLikes(state, slug) {
    const storedLikes = Math.abs(Number(localStorage.getItem(slug)));

    if(storedLikes) {
      storedLikes >= 12 ? state.storedUserLikes = 12 : state.storedUserLikes = storedLikes;
    } else {
      localStorage.setItem(slug, 1);
      state.storedUserLikes = 1;
    }
  },
  incrementLikes(state, slug) {
    state.storedUserLikes = state.storedUserLikes + 1;
    localStorage.setItem(slug, state.storedUserLikes);
  }
}
Enter fullscreen mode Exit fullscreen mode

First I am initializing the likes on the page by reading from the local storage the associated count.

I'm also adding in some checks to get the absolute value in case a user edits it to be a negative number, allowing them to click infinitely, as well as determine if the number is greater than 12 to only set the storedLikes to 12.

Now, we can extend our previous code to utilize this new piece of global state.

<script>
export default {
  data() {
    return {
      initialLikes: null,
      userLikeCount: 0,
    }
  },
  async fetch() {
    const { data } = await this.$axios.get(`/.netlify/functions/fetch_likes_for_blog?slug=${this.$route.params.slug}`);
    this.initialLikes = data.likes;
  },
  mounted() {
    this.$store.commit('initializeLikes', this.$route.params.slug);
  },
  fetchOnServer: false,
  methods: {
    async addLike() {
      if(this.storedUserLikes < 12) {
        this.initialLikes++;
        this.$store.commit('incrementLikes', this.$route.params.slug);
        this.incrementLikes();
      }
    },
    async incrementLikes() {
      await this.$axios.post(`/.netlify/functions/register-like?slug=${this.$route.params.slug}`);
    }
  },
  computed: {
    likesMaxed() {
      return this.userLikeCount >= 12;
    },
    storedUserLikes() {
      return this.$store.state.storedUserLikes;
    }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

We start by calling our Vuex mutation initializeLikes in the mounted() hook since localStorage becomes available to us then, then we use a computed property storedUserLikes to retrieve the likes from our store for that blog (if any exist).

Awesome! We now have a like counter that is persistent across multiple sessions and page visits. It's up to you how you want to render the like count so use your imagination! If you'd want to do something similar to my battery, feel free to check out my source code on Github, as I've decided to open source my site.

If you'd prefer a more traditional "like button" look, here is a simple one with Tailwind to get you started!

<template>
  <button @click="addLike" class="focus:outline-none" :class="{'text-red-600' : likesMaxed}">
    {{initialLikes}}
    <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"></path></svg>
  </button>
</template>
Enter fullscreen mode Exit fullscreen mode

Wrapping up

If you've made it this far, good job! I can't wait to (hopefully) see some more dynamic like counters on statically generated Nuxt sites in the future.

If you do decide to implement one, let me know on Twitter! I'd love to see what you come up with.

Thanks for reading!

This article was originally posted on my blog

Discussion

pic
Editor guide