Add a Likes counter to your website with Cloudflare workers
Tags: webdev, react, serverless, typescript
Published: 2023-09-01 09:00:40


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](https://www.mongodb.com/) 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](https://developers.cloudflare.com/workers/wrangler/))

**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


```bash
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](https://blog-images-prod.s3.amazonaws.com/a846acd5-6c6b-467a-ad0f-c7811dc6c3d2)


**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.](https://developers.cloudflare.com/workers/learning/how-kv-works)


Run the following command :


```bash
npx wrangler kv:namespace create LIKES
```


![Untitled.png](https://blog-images-prod.s3.amazonaws.com/dd0e867a-8030-4dd7-aff7-2c7d81fe1ce3)


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


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


![Screenshot of wrangler.toml file after adding kv namespace binding](https://blog-images-prod.s3.amazonaws.com/269b7e6a-da7a-4bd3-96e3-3691088d9e4c)


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](https://developers.cloudflare.com/workers/platform/limits/#kv-limits).


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.

2. `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`.


	```bash
	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 


	```bash
	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](https://s3.us-west-2.amazonaws.com/secure.notion-static.com/7974c0a4-ba94-4286-afad-79aa2cd583b5/Untitled.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=AKIAT73L2G45EIPT3X45%2F20230901%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20230901T090007Z&X-Amz-Expires=3600&X-Amz-Signature=728b173f5719def159624798310b7201a940124eff1662bd8a9747d60617b55f&X-Amz-SignedHeaders=host&x-id=GetObject)


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


	```bash
	npx wrangler deploy
	```


![Untitled.png](https://blog-images-prod.s3.amazonaws.com/80e9fe28-3c74-484f-a920-d648a626a2a1)


Open the URL in your browser


![Untitled.png](https://blog-images-prod.s3.amazonaws.com/b93c20de-d517-42b5-8949-d30ce2d53d39)


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](https://blog-images-prod.s3.amazonaws.com/22795bb6-17ec-4854-9179-2f8bfc9f5755)


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


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


![Accessing URL object values inside the worker](https://blog-images-prod.s3.amazonaws.com/79874846-9166-4243-84bc-9a92c672b5d8)


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, 


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


![Using substring to match pathname and send custom response](https://blog-images-prod.s3.amazonaws.com/27eef5cc-7695-4b7c-8a61-b456b82b046f)


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


![Screenshot of chrome tab accessing worker https address](https://blog-images-prod.s3.amazonaws.com/315665f0-eb97-4160-af17-4276bf0eb807)


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.


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


Add the following code to handle the other  pathnames


```bash
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)


```bash
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](https://blog-images-prod.s3.amazonaws.com/74d4c424-6418-4312-925b-a7be2806fcd8)


![Screenshot of chrome browser incrementing likes by hitting /incrementLikes endpoint](https://blog-images-prod.s3.amazonaws.com/701ccd6c-e142-424f-9a44-c7e621e4ca48)


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


![Screenshot of chrome browser incrementing likes by hitting /incrementLikes endpoint multiple times](https://blog-images-prod.s3.amazonaws.com/0e7cbe6b-2d84-4e19-9374-20687cc6bd3d)


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](https://blog-images-prod.s3.amazonaws.com/8c083f2d-0670-4217-9ac8-2843c8a17e8a)


Let’s test the decrement counter also,


![Screenshot of browser hitting /decrementLikes endpoint](https://blog-images-prod.s3.amazonaws.com/6497d033-55d9-4725-b383-e3e70c6da75d)


Let’s verify by visiting /getLikes endpoint


![Screenshot of verifying like count by hitting /getLikes endpoint after decrementing likes count](https://blog-images-prod.s3.amazonaws.com/db609117-71b7-4f98-b63b-a6eab8788517)


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](https://github.com/devnrj07/neerajmukta.com/blob/d78166abab6a3fe2d3e3cce64cededc0b1549061/src/components/LikeButton.tsx#L5-L36) page.


```bash
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](https://github.com/devnrj07/neerajmukta.com/blob/d78166abab6a3fe2d3e3cce64cededc0b1549061/src/hooks/useLikeButton.ts#L9-L53).


```bash
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](https://github.com/devnrj07/neerajmukta.com/blob/d78166abab6a3fe2d3e3cce64cededc0b1549061/src/utils/APIRequests/index.ts#L1-L14) 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.


```bash
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.


```bash
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 revisted.


![Like button without localstorage](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rxgapww51a13bjwzte0w.gif)


> 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.


```bash
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](https://blog-images-prod.s3.amazonaws.com/d3b9a2f5-de11-4199-bcc7-5c38f8fcb0a0)


**After**


![Untitled.png](https://blog-images-prod.s3.amazonaws.com/9025afab-9df7-40bb-b5ee-04630f4e36ce)


**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.


![Like button with localstorage](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cwvp07g9migpbdoxlxwy.gif)


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](https://vercel.com/docs/storage/vercel-kv) so we can implement the same APIs using [Vercel Edge Functions](https://vercel.com/docs/functions/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](https://neerajmukta.com/) and drop a like. This post was published using a cross-platform publishing tool [Buzpen](https://buzpen.com/).