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:
- Create a to-dos data structure
- Write the to-dos into Cloudflare’s Workers KV
- Retrieve the to-dos from Workers KV
- Return an HTML page to the client
- Create new to-dos in the UI
- Mark to-dos as complete in the UI
- 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:
- Cloudflare account and API keys.
- 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.
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.
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:
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:
- Write data to KV
- Render data from KV
- 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.
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/codeforgeek.com/htdocs/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:
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:
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.
<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:
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:
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:
In handleRequest, we use the data from KV to call the HTML function and create a Response based on it:
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.
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.
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 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 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 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:
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:
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 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.
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 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:
<!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.