In this guide, we’re going to make a basic to-do list application with a simple web development stack—HTML, CSS, and JavaScript. And we’ll use Cloud Workers to serve the app itself and Cloudflare Workers KV—a key-value data store—to store the data.

Here are the steps we’re going to take:

  1. Create a to-dos data structure
  2. Write the to-dos into Cloudflare’s Workers KV
  3. Retrieve the to-dos from Workers KV
  4. Return an HTML page to the client
  5. Create new to-dos in the UI
  6. Mark to-dos as complete in the UI
  7. Take care of to-do updates

In the first part, we’re figuring out the Cloudflare/API-level things we need to know about Workers and KV. In the second, we’re building a UI to interact with the data.

Let’s get down to business.

What Tools You’ll Need to Use

To start working on the to-do list app, you’ll need:

  1. Cloudflare account and API keys.
  2. Installed Wrangler and access to the command-line (you’ll need a Cloudflare account for that).

If you don’t know what Wrangler is, it’s an open-source command-line tool made by the Cloudflare team, which makes it easy to create new projects, preview, and deploy them in a very straightforward way.

To install Wranger, run the following command in the terminal.

npm i @cloudflare/wrangler -g

What Are Workers Exactly?

A huge part of the app we’re going to make revolves around KV. So let’s figure out what these Workers are.

In fact, Service Workers are background scripts, which run in the browser alongside your app. Cloudflare Workers are super-powered versions of these scripts: the Worker scripts run on Cloudflare’s edge network, in-between the app and browser.

That lets devs build an app directly on edge, not depending on origin servers entirely.

And that’s exactly what we’re going to build—a globally available app, with low-latency, yet still easy-to-use due to JavaScript.

Creating a Worker Project

First, jump to the Workers tab in your Cloudflare account and launch the Workers editor. In the command line, generate a project by passing in a project name—todos or any other name you choose.

wrangler generate todos
cd todos

Wrangler’s default template supports JavaScript-based projects—both creating and deployment—including Webpack support.

In the todos directory, index.js stands for the entry-point to the Cloudflare app.

Let’s take a closer look at the index.js file. It starts with an addEventListener listener call:

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

/**
 * Fetch and log a request
 * @param {Request} request
 */

async function handleRequest(request) {
  return new Response('Hello worker!', { status: 200 })
}

Workers’ applications always listen for the fetch event, which represents an incoming request from a client. At the moment a browser makes a request to the URL the Workers project’s on, the fetch event will serve this incoming request.

Like any other event listener, it takes a function argument which is what happens when that event comes in. In this case, we’re going to use event.respondWith meaning ‘respond to the client request with this function—handleRequest(event.request function).

But all we care about here is returning a response to the client: whenever our request comes in, we want to match it with the response back to the client.

We’re using return new Response to return Hello worker! text and status:200 which represents a successful response we’re sending to the user.

This way, your Cloudflare script will serve new responses directly from the cloud network, instead of a standard server that would accept requests and return responses. Cloudflare Workers lets you respond quickly by constructing responses directly on edge.

Making a To-Do List Application

Now, it’s time to start building the to-do list web app. As I’ve mentioned in the beginning, there are three steps:

  1. Write data to KV
  2. Render data from KV
  3. Add to-does from the UI

Let’s go through them one by one.

Step 1: Writing Data to Cloudflare’s Workers KV

We’re going to use Cloudflare’s Workers KV to fill our to-do list with data.

The basic concept that you need to know is namespaces. Basically, it’s a representation of your Workers KV namespace—a little KV datastore you’ve set up in your Cloudflare account—in the code.

To get started with KV, we set up a namespace that’ll store our cached data. Use Wrangler to make a new namespace and get its namespace ID.

wrangler kv:namespace create "TODOS"

Next, copy the namespace ID you’ve just created, and define a kv-namespaces key to set up your namespace in wrangler.toml file.


GeSHi Error: GeSHi could not find the language toml (using path /var/www/html/wp-content/plugins/codecolorer/lib/geshi/) (code 2)

From now on, the defined TODOS namespace will be available inside of your codebase.

Now, let’s talk about the KV API. A KV namespace has three methods you can use to interface with your cache: get, put, delete.

Let’s start with an initial set of data we’re going to put inside the cache using the put method. Instead of a simple array of todos, we’ll define a defaultData object (as you may want to keep metadata and other info inside this cache object later on).

JSON.stringify will put a simple string into the cache:

async function handleRequest(request) {
 
  const defaultData = {
    todos: [
      {
        id: 1,
        name: 'Finish Codeforgeek guide',
        completed: false,
      },
    ],
  }
  TODOS.put('data', JSON.stringify(defaultData))

  // ...previous code
}

Worker KV data store is eventually consistent. That means, if you write something to the cache, it’ll become available eventually. Yep, it’s possible to read the value back from the cache the moment you’ve written it, but you’ll only find that it hasn’t been updated yet.

And as there’s data in the cache and the cache is eventually consistent, we should slightly change the code.

First, we should read from the cache, parsing the value back out, and using it as the data source (if it actually exists).

If it doesn’t exist, we’ll refer to defaultData which will be our data source (yet only for now), while setting it in the cache for future use.

After breaking our code into a few functions, here’s what we get:

const defaultData = {
  todos: [
    {
      id: 1,
      name: 'Finish the Cloudflare Workers blog post',
      completed: false,
    },
  ],
}

const setCache = data => TODOS.put('data', data)
const getCache = () => TODOS.get('data')

async function getTodos(request) {
  // ... previous code

  let data
  const cache = await getCache()
  if (!cache) {
    await setCache(JSON.stringify(defaultData))
    data = defaultData
  } else {
    data = JSON.parse(cache)
  }
}

Step 2: Rendering Data From Cloudflare’s KV

Now, as there are data in our code (the cached data object for our
to-do app), we should show this data on the screen.

Let’s make a new HTML variable in our Workers script, then use it to build an HTML template we can pass to the client. Make a new Response in handleRequest, with a Content-Type header of text/HTML.

const html = `<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <title>Todos</title>
  </head>
  <body>
    <h1>Todos</h1>
  </body>
</html>
`

async function handleRequest(request) {
  const response = new Response(html, { headers: { 'Content-Type': 'text/html' } })
  return response
}

To add some data to the static HTML site, use a div tag with todos id:

<body>
  <h1>Todos</h1>
  <div id="todos"></div>
</body>

Taking into account that body, we can make a script that would take a todos array, go through it, and create a div element for each todo in the array:

<script>
  window.todos = []
  var todoContainer = document.querySelector('#todos')
  window.todos.forEach(todo => {
    var el = document.createElement('div')
    el.textContent = todo.name
    todoContainer.appendChild(el)
  })
</script>

Our static page can now take in window.todos and render HTML based on it. But we still haven’t passed in any data from KV. We need to make a couple of code adjustments to do that.

First, let’s make a function our of the html variable. It will take in an argument (todos) which will fill the window.todos variable:

const html = todos => `
<!doctype html>
<html>
  <!-- ... -->
  <script>
    window.todos = ${todos}
    var todoContainer = document.querySelector("#todos");
    // ...
  <script>
</html>
`

In handleRequest, we use the data from KV to call the HTML function and create a Response based on it:

async function handleRequest(request) {
  let data

  // Set data using cache or defaultData from previous section...

  const body = html(JSON.stringify(data.todos))
  const response = new Response(body, {
    headers: { 'Content-Type': 'text/html' },
  })
  return response
}

Step 3: Adding Todos From the User Interface

So far, we’ve made a Cloudflare Worker taking data from Cloudflare KV and rendering a static page based on this data. The page first reads the data, then makes a todo list based on it.

We already know how to add to-dos using KV API TODOS.put(newData), but how do we update it from inside the interface?

To make it work, we handle the second route in our script that’ll watch for PUT requests to /.

Add this to the worker and into the handleRequest. In case the request method is a PUT, it’ll take the request body and update the cache.

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

const setCache = data => TODOS.put('data', data)

async function updateTodos(request) {
  const body = await request.text()
  try {
    JSON.parse(body)
    await setCache(body)
    return new Response(body, { status: 200 })
  } catch (err) {
    return new Response(err, { status: 500 })
  }
}

async function handleRequest(request) {
  if (request.method === 'PUT') {
    return updateTodos(request)
  } else {
    // Defined in previous code block
    return getTodos(request)
  }
}

The script is pretty simple—we check whether a request is a PUT, and wrap what’s left of the code into a ‘try/catch’ block.

Then we parse the body of the request that comes in, making sure it’s JSON. We do that before updating the cache with the new data and returning it to the user.

If anything goes wrong, we return a 500 status code. If we deal with an HTTP method other than PUT—like GET or DELETE—we return a 404 status code.

Using the script, we can add ‘dynamic’ functionality to our HTML page.

First, create an input for our to-do ‘name’, and a button for submitting the to-do.

<div>
  <input type="text" name="name" placeholder="A new todo"></input>
  <button id="create">Create</button>
</div>

Then we add a corresponding JavaScript function to watch for clicks on the button. Once someone clicks on the button, the browser will PUT to / and submit the to-do.

var createTodo = function() {
  var input = document.querySelector('input[name=name]')
  if (input.value.length) {
    todos = [].concat(todos, {
      id: todos.length + 1,
      name: input.value,
      completed: false,
    })  
    fetch('/', {
      method: 'PUT',
      body: JSON.stringify({ todos: todos }),
    })
  }
}

document.querySelector('#create').addEventListener('click', createTodo)

What about the local UI? As you already know, KV cache is eventually consistent. Even if we update the worker to read from the cache, there are no guarantees the cache will be up-to-date.

So let’s update the list of to-dos locally—we’re going to take our code for rendering the list, and transform it into a reusable function populateTodos. It will be called when the page loads and the cache request is finished:

var populateTodos = function() {
  var todoContainer = document.querySelector('#todos')
  todoContainer.innerHTML = null
  window.todos.forEach(todo => {
    var el = document.createElement('div')
    el.textContent = todo.name
    todoContainer.appendChild(el)
  })
}

populateTodos()

var createTodo = function() {
  var input = document.querySelector('input[name=name]')
  if (input.value.length) {
    todos = [].concat(todos, {
      id: todos.length + 1,
      name: input.value,
      completed: false,
    })
    fetch('/', {
      method: 'PUT',
      body: JSON.stringify({ todos: todos }),
    })
    populateTodos()
    input.value = ''
  }
}

document.querySelector('#create').addEventListener('click', createTodo)

Deploy the new Worker, and you’ve got a dynamic to-do list as a result.

Step 4: Updating Todos From the UI

Finally, we need to update our to-dos—mark them as completed. We can now update the list data in our cache, which is proved by the createTodo function.

The populateTodos function will be updated to generate a div for each to-do. Plus, we’ll move the to-do’s name into a child element of that div:

var populateTodos = function() {
  var todoContainer = document.querySelector('#todos')
  todoContainer.innerHTML = null
  window.todos.forEach(todo => {
    var el = document.createElement('div')
    var name = document.createElement('span')
    name.textContent = todo.name
    el.appendChild(name)
    todoContainer.appendChild(el)
  })
}

Okay, so we’ve designed the client-side part of the code: it takes an array of to-dos in and renders out a list of HTML elements.

But there’s a number of things we’ve been doing but haven’t used yet. For example, I included IDs and updated the completed value on a to-do.

It’d be nice to start with signifying each to-do’s ID in the HTML. This way, we’ll refer to the element later to correspond to the to-do in the JS part of the code.

When we generate a div element for each to-do, we can attach a data attribute called to-do to each div:

window.todos.forEach(todo => {
  var el = document.createElement('div')
  el.dataset.todo = todo.id
  // ... more setup

  todoContainer.appendChild(el)
})

Now, each div for a todo has an attached data attribute inside our HTML:

'
<div data-todo="1"></div>
<div data-todo="2"></div>

Next, we make a checkbox for each to-do, unchecked for new todos by default. We mark the box as checked as the element is rendered in the window:

window.todos.forEach(todo => {
  var el = document.createElement('div')
  el.dataset.todo = todo.id

  var name = document.createElement('span')
  name.textContent = todo.name

  var checkbox = document.createElement('input')
  checkbox.type = 'checkbox'
  checkbox.checked = todo.completed ? 1 : 0

  el.appendChild(checkbox)
  el.appendChild(name)
  todoContainer.appendChild(el)
})

The checkbox is configured to reflect the value of completed on each to-do. Still, it doesn’t update when we check the box. Yet.

To correct this, we need to add an event listener on the click event, calling completeTodo.

Inside of the function, we’ll inspect the checkbox element, find its parent (the to-do div), and use the to-do data attribute on it to find the matching to-do in our data.

We can switch the value of completed, update the data, or re-render the UI, given that to-do:

var completeTodo = function(evt) {
  var checkbox = evt.target
  var todoElement = checkbox.parentNode

  var newTodoSet = [].concat(window.todos)
  var todo = newTodoSet.find(t => t.id == todoElement.dataset.todo)
  todo.completed = !todo.completed
  todos = newTodoSet
  updateTodos()
}

The result? We’ve created a system checking the to-dos variable, updating KV cache with that value, and re-rendering the UI using the data it has locally.

TODO List using Cloudflare workers

What’s Next?

So we’ve got an HTML/JS application, almost fully static, powered by Cloudflare KV and Workers, served at the edge. Sounds great.

But there’s another question: how to implement per-user caching? Right now, the cache key is just ‘data’: anyone who visits the site can share the list with any person.

Still, as we have the information inside the worker, it won’t be hard to make it user-specific.

Here’s how you can implement per-user caching by generating the cache key based on the requesting IP:

const ip = request.headers.get('CF-Connecting-IP')
const myKey = `data-${ip}`
const getCache = key => TODOS.get(key)
getCache(myKey)

One more deploy, and you have the Workers project up and running.
And here’s how the final script should look like:

const html = todos => `
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Todos</title>
    <link href="https://cdn.jsdelivr.net/npm/tailwindcss/dist/tailwind.min.css" rel="stylesheet"></link>
  </head>

  <body class="bg-blue-100">
    <div class="w-full h-full flex content-center justify-center mt-8">
      <div class="bg-white shadow-md rounded px-8 pt-6 py-8 mb-4">
        <h1 class="block text-grey-800 text-md font-bold mb-2">Todos</h1>
        <div class="flex">
          <input class="shadow appearance-none border rounded w-full py-2 px-3 text-grey-800 leading-tight focus:outline-none focus:shadow-outline" type="text" name="name" placeholder="A new todo"></input>
          <button class="bg-blue-500 hover:bg-blue-800 text-white font-bold ml-2 py-2 px-4 rounded focus:outline-none focus:shadow-outline" id="create" type="submit">Create</button>
        </div>
        <div class="mt-4" id="todos"></div>
      </div>
    </div>
  </body>

  <script>
    window.todos = ${todos}

    var updateTodos = function() {
      fetch("/", { method: 'PUT', body: JSON.stringify({ todos: window.todos }) })
      populateTodos()
    }

    var completeTodo = function(evt) {
      var checkbox = evt.target
      var todoElement = checkbox.parentNode
      var newTodoSet = [].concat(window.todos)
      var todo = newTodoSet.find(t => t.id == todoElement.dataset.todo)
      todo.completed = !todo.completed
      window.todos = newTodoSet
      updateTodos()
    }

    var populateTodos = function() {
      var todoContainer = document.querySelector("#todos")
      todoContainer.innerHTML = null

      window.todos.forEach(todo => {
        var el = document.createElement("div")
        el.className = "border-t py-4"
        el.dataset.todo = todo.id

        var name = document.createElement("span")
        name.className = todo.completed ? "line-through" : ""
        name.textContent = todo.name

        var checkbox = document.createElement("input")
        checkbox.className = "mx-4"
        checkbox.type = "checkbox"
        checkbox.checked = todo.completed ? 1 : 0
        checkbox.addEventListener('click', completeTodo)

        el.appendChild(checkbox)
        el.appendChild(name)
        todoContainer.appendChild(el)
      })
    }

    populateTodos()

    var createTodo = function() {
      var input = document.querySelector("input[name=name]")
      if (input.value.length) {
        window.todos = [].concat(todos, { id: window.todos.length + 1, name: input.value, completed: false })
        input.value = ""
        updateTodos()
      }
    }

    document.querySelector("#create").addEventListener('click', createTodo)
  </script>
</html>
`

const defaultData = { todos: [] }

const setCache = (key, data) => TODOS.put(key, data)
const getCache = key => TODOS.get(key)

async function getTodos(request) {
  const ip = request.headers.get('CF-Connecting-IP')
  const myKey = `data-${ip}`
  let data
  const cache = await getCache(myKey)
  if (!cache) {
    await setCache(myKey, JSON.stringify(defaultData))
    data = defaultData
  } else {
    data = JSON.parse(cache)
  }
  const body = html(JSON.stringify(data.todos || []))
  return new Response(body, {
    headers: { 'Content-Type': 'text/html' },
  })
}

async function updateTodos(request) {
  const body = await request.text()
  const ip = request.headers.get('CF-Connecting-IP')
  const myKey = `data-${ip}`
  try {
    JSON.parse(body)
    await setCache(myKey, body)
    return new Response(body, { status: 200 })
  } catch (err) {
    return new Response(err, { status: 500 })
  }
}

async function handleRequest(request) {
  if (request.method === 'PUT') {
    return updateTodos(request)
  } else {
    return getTodos(request)
  }
}

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

Check the result right here. You may now play with design, making your to-do list look better, or opt for better security, speed, and so on.

By the way, if you want the finished code for this project, check it on GitHub.

About the Author

Vitaly Kuprenko is a writer for Cleveroad. It’s a web and mobile app development company with headquarters in Ukraine. He enjoys writing about technology and digital marketing.