Your first Pagelove application

In this tutorial we'll build a working shopping list — a page where you tick items off as you buy them, add new items, and delete the ones you no longer need. Every change is saved on the server automatically, so the list survives a page reload.

Along the way you'll meet the heart of Pagelove: writing fragments of a page straight back to the server with PUT, POST, and DELETE, and granting permission for those writes with an authorization rule. You'll write plain HTML, CSS, and a little JavaScript — there's no database to set up and no server code to write.

Let's start with a single index.html file and build it up together.

First, let's put some scaffolding in place: an HTML page with a heading and a simple list, giving each item a checkbox so we can tick it off once we've bought it.

<!doctype html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <title>Simple Todo List</title>
    </head>
    <body>
        <h1>Shopping List</h1>
        <ul id="todo-list">
            <li><input type="checkbox">Get Milk</li>
            <li><input type="checkbox">Buy Eggs</li>
            <li><input type="checkbox">Make pancakes</li>
        </ul>
    </body>
</html>

Open index.html in your browser and you'll see the list: three items, each with a checkbox. It works, but it's not very exciting yet. Let's make it so we can visibly cross items off.

First we'll add a little CSS inside the body. It looks for any list item whose checkbox is checked and strikes the text through:

<style>
    li:has(input:checked) {
        text-decoration: line-through;
    }
</style>

Now, at the bottom of the body, we'll add a small script. When a checkbox changes, it toggles the checked attribute on the input — not just the live property. That distinction matters, and we'll see why in a moment:

<script type="module">
    document
        .querySelectorAll("input[type=checkbox]")
        .forEach((listItem) =>
            listItem.addEventListener("change", (event) => {
                event.target.toggleAttribute("checked");
            }),
        );
</script>

Try it: check a box and the whole item gets a line through it. Now reload the page. Notice that your ticks are gone — the page has reset to its original three unchecked items. For a shopping list that's no good; we need the changes to stick.

This is where Pagelove comes in.

First, let's load the Pagelove client library by adding this script inside the <head> at the top of the page:

<script type="module" src="https://pagelove.github.io/beta-js/pagelove.mjs"></script>

Now let's modify the script we just wrote so that it saves the updated list item every time we change it:

<script type="module">
    document
        .querySelectorAll("input[type=checkbox]")
        .forEach((listItem) =>
            listItem.addEventListener("change", (event) => {
                event.target.toggleAttribute("checked");
                const li = event.target.closest("li");
                li.PUT();
            }),
        );
</script>

One more thing: Pagelove won't write anything to the server unless a rule says it may. We grant that permission with an AuthorizationRule — a small block of HTML microdata we can put anywhere in the document, or even in another file. For now, let's add it to the body of index.html. This rule says: allow anyone to PUT changes to a list item inside #todo-list.

<div hidden itemscope itemtype="https://pagelove.org/AuthorizationRule">
    <meta itemprop="actor" content="*" />
    <meta itemprop="resource" content="/index.html" />
    <meta itemprop="method" content="PUT" />
    <meta itemprop="selector" content="#todo-list li" />
    <meta itemprop="action" content="allow" />
</div>

That's quite a bit of stuff! Just to make sure we're together, your index.html should now look something like this:

<!doctype html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <title>Simple Todo List</title>
        <script type="module" src="https://pagelove.github.io/beta-js/pagelove.mjs"></script>
    </head>
    <body>
        <style>
            li:has(input:checked) {
                text-decoration: line-through;
            }
        </style>
        <h1>Shopping List</h1>
        <ul id="todo-list">
            <li><input type="checkbox" />Get Milk</li>
            <li><input type="checkbox" />Buy Eggs</li>
            <li><input type="checkbox" />Make pancakes</li>
        </ul>
        <script type="module">
            document
                .querySelectorAll("input[type=checkbox]")
                .forEach((listItem) =>
                    listItem.addEventListener("change", (event) => {
                        event.target.toggleAttribute("checked");
                        const li = event.target.closest("li");
                        li.PUT();
                    }),
                );
        </script>
        <div hidden itemscope itemtype="https://pagelove.org/AuthorizationRule">
            <meta itemprop="actor" content="*" />
            <meta itemprop="resource" content="/index.html" />
            <meta itemprop="method" content="PUT" />
            <meta itemprop="selector" content="#todo-list li" />
            <meta itemprop="action" content="allow" />
        </div>
    </body>
</html>

Now try it again: check an item and reload the page. This time the checkbox stays checked, exactly as you'd want from a shopping list. The PUT() method you called on the list item sent that updated fragment of the page back to Pagelove, and because your authorization rule permits it, Pagelove wrote the fragment permanently into your host's datastore.

Adding items

Tracking what we've bought is useful, but a shopping list also needs a way to add things. Let's put a small form underneath the list:

<div>
    <label for="new-item">Add item</label>
    <input id="new-item" type="text">
    <button command="--add-item" commandfor="todo-list">Add</button>
</div>

Notice that the button uses the Invoker Commands API — a recent browser feature that lets a button declare which element it acts on (commandfor) and what it does (command). To respond to it, we'll add a little code beneath our previous event listener:

document
    .querySelector("#todo-list")
    .addEventListener("command", (event) => {
        if (event.command == "--add-item") {
            const text = document.querySelector("#new-item").value;
            event.target.POST(
                `<li><input type="checkbox">${text.trim()}</li>`,
            );
        }
    });

Until now we've only changed items that were already on the list, so a PUT was the right tool. Adding a brand-new item is a different operation — a POST — so we need a second rule granting permission for it:

<div hidden itemscope itemtype="https://pagelove.org/AuthorizationRule">
    <meta itemprop="actor" content="*" />
    <meta itemprop="resource" content="/index.html" />
    <meta itemprop="method" content="POST" />
    <meta itemprop="selector" content="#todo-list" />
    <meta itemprop="action" content="allow" />
</div>

Reload the page and type something into the box. Notice that pressing Add appends a new item to your list — and, because of the POST, it's saved on the server just like the checkboxes.

Deleting items

Some weeks you'll want different things, so let's add the last piece: removing an item from the list with a DELETE.

First, add a delete button inside every <li>, and a little CSS in the <style> tag to style it:

<button class="delete">&#x1F5D1;</button>
button.delete {
    border: none;
    cursor: pointer;
    background-color: transparent;
}

Now add another event listener to the script, so clicking a delete button removes its list item:

document.querySelectorAll("li button.delete").forEach((button) => {
    button.addEventListener("click", (event) => {
        button.closest("li").DELETE();
    });
});

We also want newly-added items to come with a delete button of their own, so let's update the POST in our add-item handler to include one:

document
    .querySelector("#todo-list")
    .addEventListener("command", (event) => {
        if (event.command == "--add-item") {
            const text = document.querySelector("#new-item").value;
            event.target.POST(
                `<li><input type="checkbox">${text.trim()} <button class="delete">&#x1F5D1;</button></li>`,
            );
        }
    });

As before, Pagelove needs permission to make this change. This time, though, we don't need a brand-new rule: a DELETE targets the same #todo-list li selector as our original PUT, so we can simply add DELETE as a second method on that existing rule:

<div hidden itemscope itemtype="https://pagelove.org/AuthorizationRule">
    <meta itemprop="actor" content="*" />
    <meta itemprop="resource" content="/index.html" />
    <meta itemprop="method" content="PUT" />
    <meta itemprop="method" content="DELETE" />
    <meta itemprop="selector" content="#todo-list li" />
    <meta itemprop="action" content="allow" />
</div>

Our full application should now look like this:

<!doctype html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <title>Simple Todo List</title>
        <script type="module" src="https://pagelove.github.io/beta-js/pagelove.mjs"></script>
    </head>
    <body>
        <style>
            li:has(input:checked) {
                text-decoration: line-through;
            }

            button.delete {
                border: none;
                cursor: pointer;
                background-color: transparent;
            }
        </style>
        <h1>Shopping List</h1>
        <ul id="todo-list">
            <li>
                <input type="checkbox" />Get Milk
                <button class="delete">&#x1F5D1;</button>
            </li>
            <li>
                <input type="checkbox" />Buy Eggs
                <button class="delete">&#x1F5D1;</button>
            </li>
            <li>
                <input type="checkbox" />Make pancakes
                <button class="delete">&#x1F5D1;</button>
            </li>
        </ul>
        <div>
            <label for="new-item">Add item</label>
            <input id="new-item" type="text" />
            <button command="--add-item" commandfor="todo-list">Add</button>
        </div>
        <script type="module">
            document
                .querySelectorAll("input[type=checkbox]")
                .forEach((listItem) =>
                    listItem.addEventListener("change", (event) => {
                        event.target.toggleAttribute("checked");
                        const li = event.target.closest("li");
                        li.PUT();
                    }),
                );
            document
                .querySelector("#todo-list")
                .addEventListener("command", (event) => {
                    if (event.command == "--add-item") {
                        const text = document.querySelector("#new-item").value;
                        event.target.POST(
                            `<li><input type="checkbox">${text.trim()} <button class="delete">&#x1F5D1;</button></li>`,
                        );
                    }
                });
            document.querySelectorAll("li button.delete").forEach((button) => {
                button.addEventListener("click", (event) => {
                    button.closest("li").DELETE();
                });
            });
        </script>
        <div hidden itemscope itemtype="https://pagelove.org/AuthorizationRule">
            <meta itemprop="actor" content="*" />
            <meta itemprop="resource" content="/index.html" />
            <meta itemprop="method" content="PUT" />
            <meta itemprop="method" content="DELETE" />
            <meta itemprop="selector" content="#todo-list li" />
            <meta itemprop="action" content="allow" />
        </div>
        <div hidden itemscope itemtype="https://pagelove.org/AuthorizationRule">
            <meta itemprop="actor" content="*" />
            <meta itemprop="resource" content="/index.html" />
            <meta itemprop="method" content="POST" />
            <meta itemprop="selector" content="#todo-list" />
            <meta itemprop="action" content="allow" />
        </div>
    </body>
</html>

Making it dynamic

There's one rough edge left. We attach our event listeners once, when the page first loads — so they only bind to the items that exist at that moment. Add a new item and you'll notice its checkbox and delete button do nothing: no listener was ever attached to them.

To fix this properly, we'll bind listeners as elements appear, using the DOMSubscriber module instead of a one-off document.querySelectorAll. Update the <script> to look like this:

<script type="module">
    import { DOMSubscriber } from "https://cdn.pagelove.net/js/dom-subscriber/cde4007/index.mjs";
    DOMSubscriber.subscribe(
        document,
        "input[type=checkbox]",
        (listItem) => {
            listItem.addEventListener("change", (event) => {
                event.target.toggleAttribute("checked");
                const li = event.target.closest("li");
                li.PUT();
            });
        },
    );
    DOMSubscriber.subscribe(document, "li button.delete", (button) => {
        button.addEventListener("click", (event) => {
            button.closest("li").DELETE();
        });
    });
    document
        .querySelector("#todo-list")
        .addEventListener("command", (event) => {
            if (event.command == "--add-item") {
                const text = document.querySelector("#new-item").value;
                event.target.POST(
                    `<li><input type="checkbox">${text.trim()} <button class="delete">&#x1F5D1;</button></li>`,
                );
            }
        });
</script>

DOMSubscriber watches the live DOM for elements matching each selector and binds the right listeners the moment they appear — including items you add later. Now every checkbox and delete button works, no matter when it was created.

That's it — your first Pagelove application is complete. With nothing but HTML, CSS, and a little JavaScript, you've built a shopping list that saves every change to the server, and you've used all three write methods along the way: PUT to update an item, POST to add one, and DELETE to remove one — each gated by an authorization rule.

Where to go next