Add a Likes counter to your website with Cloudflare workers

Add a Likes counter to your website with Cloudflare workers

Instant APIs with Cloudflare workers

So you have spent time and money to build a beautiful-looking website for your product or maybe a fancy portfolio website to impress your potential clients cool! You also added Google Analytics to track traffic but you can’t tell how many people liked it based on analytics data. 😟

Well, what if I show you a simple way to track it? And the answer is simple: just add a like button ♥️. We see those everywhere on every platform today since Facebook introduced it to the world.

But why serverless solution or Cloudflare worker to be specific? Good question. Yes, we can do it the regular way with an API from a dedicated Nodejs or python web server with MongoDB approach but it will be an overkill to develop & deploy a server with a database just to keep track of like counts. (at least in my case)

Our goal here is to:

  • to store the Likes count at some global storage so that we can read the updated value anytime,

  • increase the Likes count when a user clicks our like button and decrease the Likes count when they click it again,

  • Learn how to develop and deploy an API in minutes (with Wrangler-cli)

Assumptions / Prerequisite :

  • An active Cloudflare account

  • any code editor

  • Node version > 16 installed

  • Some knowledge about React and Rest APIs

  • Basic javascript/Typescript skills

Before, jumping into the coding part first let’s try to break down the process and define some steps to implement the feature. Let’s start with the API part we need three endpoints for our use case

  1. /getTotalLikes: to get a current count of likes

  2. /incrementLikes: to increase Likes count by 1 when the user clicks the Like button

  3. /decrementLikes: to decrease Likes count by 1 when the user clicks the Like button again (unlike)

Once we have these three endpoints, we can hook them up to the react button component and also display the latest Likes count around it.

We have a small plan now.

Let’s begin building:

Step 1: Create a hello word Cloudflare worker project using the below command

npm create cloudflare@latest

It will walk you through boilerplate setup. Feel free to pick any configuration you like.

My project config :

Typescript: Always, Yes.

Deploy: Yes. It will open a live URL in the browser. (easy to deploy)

Whoami: login to Cloudflare account via browser.

Untitled.png

Step 2: Creating a KV Namespace

KV stands for key-value storage it works like a browser local storage but it’s not stored on the client’s browser but rather close to the browser in the cdns. Read more about it here.

Run the following command :

npx wrangler kv:namespace create LIKES

Untitled.png

Next, copy over values from the terminal to the wrangler.toml file of your project

kv_namespaces = [
    { binding = "<YOUR_BINDING>", id = "<YOUR_ID>" }
]

Screenshot of wrangler.toml file after adding kv namespace binding

Think of namespaces as different collections of a redis-cache, it can store. up to 10,000 documents with a max size of 25MB for each value. Read, more about the limits of KV Namespaces here.

Now, we are ready to use KV namespace inside our worker. The KV Namespace provides two methods, to put and to get the data.

1.put(key:string, value:string|ReadableStream|ArrayBuffer):Promise<void>

This method returns a Promise that you should await on in order to verify a successful update.

  1. get(key:string):Promise<string|ReadableStream|ArrayBuffer|null>

    The method returns a promise you can await on to get the value. If the key is not found, the promise will resolve with the literal value null.

     export default {
       async fetch(request, env, ctx) {
             await env.NAMESPACE.put("first-key", "1")
         const value = await env.NAMESPACE.get("first-key");
    
         if (value === null) {
           return new Response("Value not found", { status: 404 });
         }
         return new Response(value);
       },
     };
    

    or we can use cli command to set the initial key-value pair

     npx wrangler kv:key put --binding=LIKES "likes_count" "0"
    

    In the src/worker.ts file

    1. uncomment line 13 and rename MY_NAMESPACE with LIKES

    2. Inside the fetch function on line 30 add the above code to get value from the store.

As you can see we can leverage typescript to be confident about KV Namespace during runtime

worker.ts: accessing ‘LIKE’ KVNamespace inside the worker

Let’s deploy this worker by running the following command:

    npx wrangler deploy

Untitled.png

Open the URL in your browser

Untitled.png

I think you must have understood what’s happening here and how we can interact with KV storage.

Step 3: Handle API endpoints

Let’s circle back to our requirements we need an endpoint /getLikesCount which will return updated likes_count from the KV store.

We can leverage new URL () standard URL object to get pathname

URL object pattern diagram

Quickly copy and paste this code into the worker.ts file

const requestUrl = new URL(request.url);
const { hostname, pathname, search, hash } = requestUrl;

Accessing URL object values inside the worker

You can try console logging hostname, pathname, search, hash to understand URL object

But, we are interested in the pathname we can check if it matches substring /getLikes

likes count,

if (pathname.includes('getLikes')) {
            return new Response('pathname includes substring `getLikes`');
    }

Using substring to match pathname and send custom response

Let’s deploy and test our workers now by visiting /getLikes path

Screenshot of chrome tab accessing worker https address

Great! It’s working we can now respond to different paths with different responses.

Let’s build on this and add two more paths /incrementLikes and /decrementLikes.

Also, let’s use another key likes_count to track count.

Run this command to create a new key-value pair and initialize with 0.

npx wrangler kv:key put --binding=LIKES "likes_count" "0"

Add the following code to handle the other pathnames

if (pathname.includes('incrementLikes')) {
            return new Response('pathname includes substring `incrementLikes`');
        }
        if (pathname.includes('decrementLikes')) {
            return new Response('pathname includes substring `incrementLikes`');
        }

Now, we can do simple addition and subtraction to the likes_count key and return the appropriate value.

Also, since we want to consume it as API let’s send it as JSON response. I’m leaving the implementation up to you. Notice, that I’ve also added an Access-Control-Allow-Origin header in the response so that we can call this API from any domain in the browser. (to prevent CROS errors)

const likesCount = await env.LIKES.get('likes_count');
        const requestUrl = new URL(request.url);
        const { hostname, pathname, search, hash } = requestUrl;

        //handle `/getLikes` request and responded with likesCount
        if (pathname.includes('getLikes')) {
            const json = JSON.stringify({ status: 200, message: `count at ${new Date().getTime()}`, likesCount }, null, 2);
            return new Response(json, {
                headers: {
                    'content-type': 'application/json;charset=UTF-8',
                    'Access-Control-Allow-Origin': '*',
                },
            });
        }

        //handle `/incrementLikes` request and responded with updated likesCount
        if (pathname.includes('incrementLikes')) {
            let updatedCount = parseInt(likesCount || '7') + 1;
            let status = 200;
            let message = `count updated at ${new Date().getTime()}`;
            try {
                await env.LIKES.put('likes_count', updatedCount.toFixed(0));
            } catch (error) {
                console.error('Error in incrementing likes', error);
                if (likesCount) {
                    updatedCount = parseInt(likesCount);
                }
                status = 500;
                message = `failed to update count error: ${JSON.stringify(error)}`;
            }
            const json = JSON.stringify({ status, message, likesCount: updatedCount }, null, 2);
            return new Response(json, {
                headers: {
                    'content-type': 'application/json;charset=UTF-8',
                    'Access-Control-Allow-Origin': '*',
                },
            });
        }

        //handle `/decrementLikes` request and responded with updated likesCount
        if (pathname.includes('decrementLikes')) {
            let updatedCount = parseInt(likesCount || '7') - 1;
            let status = 200;
            let message = `count updated at ${new Date().getTime()}`;

            try {
                await env.LIKES.put('likes_count', updatedCount.toFixed(0));
            } catch (error) {
                console.error('Error in decrementing likes', error);
                if (likesCount) {
                    updatedCount = parseInt(likesCount);
                }
                status = 500;
                message = `failed to update count error: ${JSON.stringify(error)}`;
            }
            const json = JSON.stringify({ status, message, likesCount: updatedCount }, null, 2);
            return new Response(json, {
                headers: {
                    'content-type': 'application/json;charset=UTF-8',
                    'Access-Control-Allow-Origin': '*',
                },
            });
        }

        //handle '*' requests
        const json = JSON.stringify({ status: 404, message: `unknown/missing path` }, null, 2);
        return new Response(json, {
            headers: {
                'content-type': 'application/json;charset=UTF-8',
                'Access-Control-Allow-Origin': '*',
            },
        });

Step 4: Test the API endpoint

Let’s quickly deploy and test our worker endpoints

Screenshot of chrome browser accessing /getLikes endpoint

Screenshot of chrome browser incrementing likes by hitting /incrementLikes endpoint

You can reload the page multiple times to increment the likes’ counter

Screenshot of chrome browser incrementing likes by hitting /incrementLikes endpoint multiple times

Similarly, we should also check the current likes count again by calling /getLikes endpoint

Screenshot of browser checking likes count with /getLikes endpoint after incrementing likes

Let’s test the decrement counter also,

Screenshot of browser hitting /decrementLikes endpoint

Let’s verify by visiting /getLikes endpoint

Screenshot of verifying like count by hitting /getLikes endpoint after decrementing likes count

Congratulations 🎉, your API endpoints are ready! (Look how easy it was, right?) 😄

Now, all you gotta do is integrate them into your client app. I’m using a React app for my portfolio website so I’ll be showing you the implementation for the same.

Step 5: Integrating API into our React app

This part is quite straightforward, we need a Like button component which on clicking will call our CF worker /API endpoint and update the likes count on UI.

Here’s a simple component with Heart Icon and Count Label. Here’s the link to the GitHub page.

import EmptyHeart from "./Icons/EmptyHeart";
import FilledHeart from "./Icons/FilledHeart";

const LikeButton: React.FC<{
  liked: boolean;
  likesCount: number;
  onLike: (e: any) => void;
}> = ({ liked, likesCount, onLike }) => {
  return (
    <div className="flex gap-2 items-center justify-center p-1">
      {!liked ? (
        <span
          className="animate-pulse cursor-pointer select-none"
          onClick={onLike}
        >
          <EmptyHeart />
        </span>
      ) : (
        <span
          className="cursor-pointer select-none animate-bounce"
          onClick={onLike}
        >
          <FilledHeart />
        </span>
      )}
      <h6
        className={`italic text-white font-medium ${
          liked ? "animate-[pulse_2s_ease-in_forwards]" : ""
        }`}
      >
        {likesCount}
      </h6>
    </div>
  );
};

I created a custom hook useLikeButton to abstract the API logic on how I call the API and update the state, you can see the source code here.

const useLikeButton = () => {
  const [isLiked, setIsLiked] = useState(false);
  const [likesCount, setLikesCount] = useState(0);

  useEffect(() => {
    (async () => {
      const data = await getLikesCount();
      if (data && data.status === 200) setLikesCount(parseInt(data.likesCount));
    })();
  }, [isLiked]);

  const handleLikeButtonClick = async () => {
    setIsLiked(!isLiked);
    try {
      if (isLiked) {
        const data = await decrementLike();
        if (data && data.status === 200) {
          console.log("decrement success");
          setLikesCount(parseInt(data.likesCount));
        }
      } else {
        const data = await incrementLike();
        if (data && data.status === 200) {
          console.log("increment success");
          setLikesCount(parseInt(data.likesCount));
        }
      }
    } catch (err) {
      //roll back
      if (isLiked) setLikesCount((prev) => prev + 1);
      else setLikesCount((prev) => prev - 1);
    }
  };

  return {
    isLiked,
    likesCount,
    handleLikeButtonClick,
  };
};

incrementLike and decrementLike functions look like this again check the source code for more details.

We only need a GET request here because we are not sending any payload to the worker but you can create POST request too with CloudFlare workers.

import { ADD_LIKE, ADD_UNLIKE, GET_LIKES_COUNT } from "../constants";

export const GET = async (url: string) => {
  try {
    const response = await (await fetch(url)).json();
    return response;
  } catch (err) {
    console.error("Oops! Failed to get likes", err);
  }
};

export const getLikesCount = () => GET(GET_LIKES_COUNT);
export const incrementLike = () => GET(ADD_LIKE);
export const decrementLike = () => GET(ADD_UNLIKE);

Finally, I added the LikeButton component to the page and passed the necessary props to it from our custom hook.

const { isLiked, likesCount, handleLikeButtonClick } = useLikeButton();

return(
...
//above code hidden for brevity

<LikeButton
          liked={isLiked}
          likesCount={likesCount}
          onLike={handleLikeButtonClick}
        />

)

That is it! We should now be able to see it in action.

There’s one problem though, since we don’t have user identity attached to our likes count data we cannot tell if the user has already liked our page. This could result in the same user liking it multiple times because the button resets every time the page is refreshed or revisited.

As you can tell, this happens because our state gets reset inside React app.

Step 6: Persistence problem on the client side

One simple way to fix this issue is to use the browser’s local storage.

We will set a flag (’has-like’) in local storage and based on that update the button state.

So, whenever a user visits our page we first check if that particular key is present or not

  • If the flag is present we keep the button state as liked and when the user clicks the button we decrement it and change the state to unlike.

  • if it's not, we keep the button state as unlike, and when the user clicks the button we increment it and change the state to like.

Step 7: Adding Local storage to persist state locally

I’ll use this simple custom hook useLocalStorage to set/get local storage data, the signature will be very similar to React’s useState hook and it will be interchangeable.

type SetValue<T> = Dispatch<SetStateAction<T>>;
export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, SetValue<T>] {
  const readValue = useCallback((): T => {
    if (typeof window === "undefined") {
      return initialValue;
    }

    try {
      const item = window.localStorage.getItem(key);
      return item ? (parseJSON(item) as T) : initialValue;
    } catch (error) {
      console.warn(`Error reading localStorage key “${key}”:`, error);
      return initialValue;
    }
  }, [initialValue, key]);

  // State to store our value
  // Pass initial state function to useState so logic is only executed once
  const [storedValue, setStoredValue] = useState<T>(readValue);

  const setValue: SetValue<T> = (value) => {
    // Prevent build error "window is undefined" but keeps working
    if (typeof window === "undefined") {
      console.warn(
        `Tried setting localStorage key “${key}” even though environment is not a client`
      );
    }
    try {
      // Allow value to be a function so we have the same API as useState
      const newValue = value instanceof Function ? value(storedValue) : value;
      window.localStorage.setItem(key, JSON.stringify(newValue));
      setStoredValue(newValue);
    } catch (error) {
      console.warn(`Error setting localStorage key “${key}”:`, error);
    }
  };

  useEffect(() => {
    setStoredValue(readValue());
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return [storedValue, setValue];
}

// A wrapper for "JSON.parse()"" to support "undefined" value
function parseJSON<T>(value: string | null): T | undefined {
  try {
    return value === "undefined" ? undefined : JSON.parse(value ?? "");
  } catch {
    console.log("parsing error on", { value });
    return undefined;
  }
}

One last step, We just need to replace useState inside our custom hook with this local storage hook

Before

Untitled.png

After

Untitled.png

Time to test. Let’s refresh the page and click the like button then refresh the page again.

Voila, the button is still in a like state.

Phew! That seemed like a lot of work but trust me we saved tons of time in API development for simple use cases like this.

Another thing to note here is that Vercel also provides similar KV storage so we can implement the same APIs using Vercel Edge Functions also.

Let me know in the comments if you would like me to write a blog on that as well or just comment on how you liked this one.

Our Learnings from this blog post:

  • How to create an API endpoint using CloudFlare worker, ✅

  • How to use key-value storage for data persistence in our APIs ✅

  • how to save cost by not booting up an EC2 instance for simple APIs. ✅ 🤑

Please do visit my website and drop a like. This post was published using a cross-platform publishing tool Buzpen.