How to Build a Link in Bio Tool with Cloudflare Workers

Beginner-friendly tutorial on Cloudflare Workers and KV

Cloudflare Workers provide a globally distributed serverless environment that is highly available and scalable. In this article, we will leverage the power of Cloudflare Workers and KV, a low latency distributed key-value store to build a simple Link in Bio tool, similar to Linktree and Lnk.Bio.

A Sneak Preview

This is what the finished project will look like:

Create a Page

There is a form for creating a new page

Success Message

After successfully submitting the form, the link to share on social media will be displayed.

Profile Page

This is what people will see when they go to the link.

Initializing a new Project

Let's start by initializing a new project using the Wrangler CLI. Go ahead and run this command:

$  npx wrangler init link-in-bio

We will be using Typescript for this tutorial, so answer yes when prompted.

Creating a KV Store

Run the following commands to create a new KV store:

$ npx wrangler kv:namespace create "LINK_IN_BIO"
$ npx wrangler kv:namespace create "LINK_IN_BIO" --preview

We will also need to update the wrangler.toml file to include the namespace bindings.

...
kv_namespaces = [{ binding = "LINK_IN_BIO", id = "xxxxxxxxx", preview_id = "xxxxxxxxx"}]

Running locally

We will start up the dev server to build and run the project locally by running the following command:

$ npx wrangler dev

Creating the web templates

We need three pages for the project. The home page will have the form. The profile page will render the links and profile info of the user. Lastly, we will need a success page that will be rendered after the user successfully submitted the form, it will contain the link that will be shared on the social media profile.

The templates are standard HTML documents wrapped around a function. They are quite long to include here, so check them out on Github. There are also some TypeScript interfaces in profile_page.ts. The ProfileContent is the input type for the profile page template.

export interface Link {
  name: string;
  url: string;
}
export interface ProfileContent {
  name: string;
  picture: string;
  description?: string;
  links: Link[];
}

After creating those files, import them to index.js.

import profilePage, { ProfileContent, Link } from "./profile_page";
import homePage from "./home_page";
import successPage from "./success_page";

We will also destructure the LINK_IN_BIO, which is our KV store, from the env object. Our fetch function now looks like this:


export default {
  async fetch(request: Request, { LINK_IN_BIO }: Record<string, any>): Promise<Response> {
     ...
  }
}

Handling HTTP Requests

There are two GET requests we need to handle. If there is no path will just render the homePage.

// handle get request
if (request.method.toLowerCase() === "get") {
   const url = new URL(request.url);
   // home page route
   if (url.pathname === "/") {
      return new Response(homePage, { headers: { 'Content-Type': 'text/html' } });
    }
}

If there is a path in the URL we will render the profilePage with the page content we get from our KV store. First, we extract the key from the URL and then get the value from KV by calling the get method on LINK_IN_BIO.

// handle get request
if (request.method.toLowerCase() === "get") {
   const url = new URL(request.url);
   // home page route
   if (url.pathname === "/") {
      return new Response(homePage, { headers: { 'Content-Type': 'text/html' } });
    }

    /**
     * Additional code to handle route with a path 
     **/

    // extract key from url
    const key = url.pathname.replaceAll("/", "");
    // render profile page
    const content = await LINK_IN_BIO.get(key);
    if (content) {
       return new Response(profilePage(JSON.parse(content)), { headers: { 'Content-Type': 'text/html' } });
     }
     // key is not in the store
     return new Response("Page does not exist", { status: 404 });
}

The next thing we need to handle is the POST request. It is invoked when the form on the home page is submitted. The first thing to do is get the form data from the request. We will also create the variable content to store the profile content we will put in KV. It will have a type of ProfileContent, which is the same as the input for the profilePage template.

 // handle post request
if (request.method.toLowerCase() === "post") {
   const formData = await request.formData();
   const content: ProfileContent = { name: '', picture: '', links: [] };
}

We also need two helper functions .toBase64 will be used to convert the profile picture to a base64 string and generateId a simple unique ID generator for generating keys for our KV store.

// convert file to base64
const toBase64 = async (file: File) => {
  const buffer = await file.arrayBuffer();
  var binary = '';
  var bytes = new Uint8Array(buffer);
  var len = bytes.byteLength;
  for (var i = 0; i < len; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  const base64 = btoa(binary);
  return `data:${file.type};base64,${base64}`
};

// generate unique id
const generateId = () => {
  return btoa((Date.now() * Math.random() + ""));
}

The form on the home page allows users to add an infinite number of links to the form. Each new link input will have a number associated with it, like link_name_1 and link_url_1. You can take a look at the code on Github.

Infinite Links

We will write a little code to get all the link names and URLs from the form and group them based on their number. We map them in an object first and then store the list of links in the content variable.

 // handle post request
if (request.method.toLowerCase() === "post") {
   ...
   // map to store all links in the form
   const linkMap: Record<number, Link> = {}
   // iterate over all entries in the form data 
   for (const [key, value] of formData) {
      // extract number from the field name
      const numberMatch = key.match(/\d+/)
      const number = numberMatch ? parseInt(numberMatch[0]) : undefined;
      // add link name to linkMap
      if (key.includes("link_name")) {
         if (number && typeof value === "string")
            linkMap[number] = { ...linkMap[number], name: value }
      }
      // add link url to linkMap
      else if (key.includes("link_url")) {
         if (number && typeof value === "string")
             linkMap[number] = { ...linkMap[number], url: value }
       }
   }
   // add links to content from linkMap
   content.links = Object.values(linkMap);
}

Next, we'll get the name, description, and picture from the request and add them to our content variable.

 // handle post request
if (request.method.toLowerCase() === "post") {
   ...
   // map to store all links in the form
   const linkMap: Record<number, Link> = {}
   // iterate over all entries in the form data 
   for (const [key, value] of formData) {
       ...
       // add name and description to content
       else if ((key === "name" || key === "description") && typeof value === "string") {
          content[key] = value;
        }
        // add profile picture to content
        else if (key === "picture" && typeof value !== "string") {
           content[key] = await toBase64(value);
        }
   }
   ...
}

Okay, the last thing we need to do now is, add the content to our KV store and display the success page.

 // handle post request
if (request.method.toLowerCase() === "post") {
   ...
   // generate a unique ID
   const id = generateId();
   // add content to KV
   await LINK_IN_BIO.put(id, JSON.stringify(content));
   // display success page
   return new Response(successPage(id), { headers: { 'Content-Type': 'text/html' } });
}

Publishing

We're all done with our implementation, so let's deploy it. To publish our Cloudflare Worker we need to run the following command:

$ npx wrangler publish

Now our worker will be running across the globe. Awesome!

Conclusion

There are still some modifications we could do like adding authentication, custom IDs, and customizable profile templates. But I hope this tutorial gave you an idea of the kinds of applications you can build with Cloudflare Workers and KV.

I hope you enjoyed reading this article. You can find the source code for the project on Github and also check out the live demo.