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
/getTotalLikes
: to get a current count of likes/incrementLikes
: to increase Likes count by 1 when the user clicks the Like button/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.
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
Next, copy over values from the terminal to the wrangler.toml file of your project
kv_namespaces = [
{ binding = "<YOUR_BINDING>", id = "<YOUR_ID>" }
]
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.
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 valuenull
.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
uncomment line 13 and rename
MY_NAMESPACE
withLIKES
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
Let’s deploy this worker by running the following command:
npx wrangler deploy
Open the URL in your browser
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
Quickly copy and paste this code into the worker.ts file
const requestUrl = new URL(request.url);
const { hostname, pathname, search, hash } = requestUrl;
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`');
}
Let’s deploy and test our workers now by visiting /getLikes
path
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
You can reload the page multiple times to increment the likes’ counter
Similarly, we should also check the current likes count again by calling /getLikes endpoint
Let’s test the decrement counter also,
Let’s verify by visiting /getLikes endpoint
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
After
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.