Build a blog

In this tutorial we will build a working blog. It will have a home page of recent posts, a page for each post, an archive grouped by month, an Atom feed, and a comment box that strangers can use without being able to spoil anything.

Here is the idea we will follow the whole way through: each post is one small HTML file of data, and every page of the blog builds itself from those files on the server. Add a post file and the whole site — home page, archive, feed — already knows about it. There is no build step, no database, and no server code. By the end you will have used server-side composition, a page that answers many addresses, a rule that turns the wrong status code into the right one, and a safety net that lets the public write to your site without being able to abuse it.

You need a Pagelove site with its files open in your editor — a host from the Pagelove console, edited over WebDAV, as in Getting started. We will call the blog Field Notes; use your own site's address wherever you see field-notes.onpagelove.com.

The first post

A post is a file of data, not a page. Create a folder called data, a folder called posts inside it, and this file as data/posts/hello-world.html:

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>data: hello-world</title></head>
<body>
  <article itemscope itemtype="https://blog.example/Post" id="post-hello-world">
    <meta itemprop="slug" content="hello-world">
    <meta itemprop="status" content="Published">
    <meta itemprop="excerpt" content="The first post on Field Notes, and a look at how this blog works.">
    <meta itemprop="displayDate" content="3 August 2026">
    <meta itemprop="monthLabel" content="August 2026">
    <h1 itemprop="title">Hello, world</h1>
    <p class="byline"><time itemprop="publishedAt" datetime="2026-08-03">3 August 2026</time> · <span itemprop="author">Sam</span></p>
    <div itemprop="body">
      <p>Welcome to Field Notes. This post is a single HTML file. The rest of the
      blog is about to build itself around it.</p>
    </div>
    <h2 class="comments-title">Comments</h2>
    <ul itemprop="comments" id="comments-hello-world" class="comment-list"></ul>
  </article>
</body>
</html>

Look at what we just wrote. Everything the blog will ever need to know about this post is here as microdata: an item of type https://blog.example/Post, with each fact — the slug, the status, the title, the date — labelled by an itemprop. The empty comments list will matter later.

Visit https://field-notes.onpagelove.com/data/posts/hello-world.html in your browser. You should see the post, unstyled. It works, but nobody browses a data folder. Let's give the blog a front door.

A home page that finds posts by itself

Create index.html at the top of your site:

<!DOCTYPE html>
<html lang="en" xmlns:p="https://pagelove.org/1.0" xmlns:r="https://pagelove.org/Binding/CSS">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Field Notes</title>
</head>
<body>
  <h1>Field Notes</h1>

  <section class="excerpts" r:posts="[itemtype='https://blog.example/Post']" p:template="text/liquid">
    {% assign recent = posts | where: "status", "Published" | sort: "publishedAt" | reverse %}
    {% for post in recent limit: 4 %}
    <article class="excerpt">
      <h2><a href="/posts/{{ post.slug }}.html">{{ post.title }}</a></h2>
      <p class="byline"><time datetime="{{ post.publishedAt }}">{{ post.displayDate }}</time> · {{ post.author }}</p>
      <p class="lede">{{ post.excerpt }}</p>
    </article>
    {% endfor %}
  </section>
</body>
</html>

Two attributes do all the work here, and they need the two xmlns declarations on the <html> tag to be understood — don't leave those out.

The first, r:posts="[itemtype='https://blog.example/Post']", is a binding: it tells the server to search the whole site for elements matching that CSS selector and hand them to this section under the name posts. Our post file matches. The second, p:template="text/liquid", says the contents of the section are a Liquid template to render with what the binding found.

Load https://field-notes.onpagelove.com/index.html. There is your post: title, date, excerpt, linked to a post page we have not built yet. Now view the page source in your browser. Notice that the excerpt is right there in the HTML — the server composed the page before sending it. No JavaScript ran.

Let's prove the idea from the introduction. Copy hello-world.html to data/posts/second-thoughts.html and change its data — slug second-thoughts (in the meta, the id, and the comments list id), title "Second thoughts", a new excerpt, publishedAt of 2026-08-05 with its display date. Reload the home page. Two posts, newest first, and we never touched index.html. This is the loop you will feel for the rest of the tutorial: write data, and the pages already know.

One page for every post

We are not going to create a page per post — we'll create one page that answers for all of them. A file with :slug in its name is a parameterized route: posts/:slug.html answers requests for /posts/hello-world.html, /posts/second-thoughts.html, and any other slug, with the requested value available as request.params.slug.

Create the folder posts and this file as posts/:slug.html:

<!DOCTYPE html>
<html lang="en" xmlns:e="https://pagelove.org/Binding/Sessel" xmlns:p="https://pagelove.org/1.0">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Field Notes</title>
  <style>
    main:has([itemtype="https://blog.example/Post"]) .post-missing { display: none; }
  </style>
</head>
<body e:post="${[itemtype='https://blog.example/Post']:has([itemprop='slug']:value-equals(request.params.slug)):has([itemprop='status']:value-equals('Published'))}.first()">
  <main class="reading post">
    <p:stamp post></p:stamp>

    <div class="post-missing">
      <h1>Not found</h1>
      <p>This post doesn't exist, or it hasn't been published yet.</p>
    </div>

    <a class="back" href="/index.html">← All posts</a>
  </main>
</body>
</html>

The e:post attribute is a binding again, this time written in Sessel, the platform's expression language. Read it from the inside out: find the elements of type Post, keep the one whose slug property equals the slug from the address, keep it only if its status is Published, and take the first match. The ${…} holds a CSS selector. The expression lives inside a double-quoted HTML attribute, so every string within it uses single quotes — Sessel and CSS selectors accept both styles.

<p:stamp post></p:stamp> then stamps whatever the binding found — the post's whole <article> — into the page at that spot. If the binding found nothing, nothing is stamped, and the "Not found" block remains visible (the <style> rule hides it whenever a post article is present).

Click through from the home page. Both post links now work, served by this one file. Then try an address we never wrote, /posts/nonsense.html — you get the Not found message. One template, every post, and a tidy fallback.

Making the 404 real

Our Not found page has a flaw you can't see: its status code. The route answered, the template composed, so the server said 200 OK — a "success" that politely displays "Not found". Browsers don't care, but search engines and feed readers do.

The fix is a Processor: a rule that runs after a page composes and adjusts the response. Create processors.html at the top of the site:

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>processors</title></head>
<body>
  <div itemscope itemtype="https://pagelove.org/Processor">
    <meta itemprop="resource" content="/posts/*">
    <meta itemprop="method" content="GET">
    <meta itemprop="status" content="200">
    <div itemprop="when" itemscope itemtype="https://pagelove.org/Sessel">
      <script itemprop="source" type="text/sessel">
@schema Context url("https://pagelove.org/Context");
Context.response.body.contains("blog.example/Post") == false
      </script>
    </div>
    <div itemprop="action" itemscope itemtype="https://pagelove.org/Sessel">
      <script itemprop="source" type="text/sessel">
@schema Context url("https://pagelove.org/Context");
Context.response.status = 404
      </script>
    </div>
  </div>
</body>
</html>

In words: for a GET under /posts/ that composed with status 200, if the finished page contains no Post item, change the status to 404. The page body stays exactly as it was — only the status line changes.

See it work from your terminal:

curl -sI https://field-notes.onpagelove.com/posts/nonsense.html | head -1

You should see HTTP/2 404. Run it again for hello-world.html and you get 200. The truth and the status now agree.

The archive

The archive is the home page's technique with one new move: grouping. Create archive.html:

<!DOCTYPE html>
<html lang="en" xmlns:p="https://pagelove.org/1.0" xmlns:r="https://pagelove.org/Binding/CSS">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Archive — Field Notes</title>
</head>
<body>
  <h1>Archive</h1>
  <p>Every post, newest first, grouped by month.</p>

  <section class="archive" r:posts="[itemtype='https://blog.example/Post']" p:template="text/liquid">
    {% assign all = posts | where: "status", "Published" | sort: "publishedAt" | reverse %}
    {% if all.size == 0 %}<p>No posts yet.</p>{% endif %}
    {% assign last_m = "" %}
    {% for post in all %}
      {% if post.monthLabel != last_m %}<h2>{{ post.monthLabel }}</h2>{% assign last_m = post.monthLabel %}{% endif %}
      <div class="archive-row"><a href="/posts/{{ post.slug }}.html">{{ post.title }}</a> <time datetime="{{ post.publishedAt }}">{{ post.displayDate }}</time></div>
    {% endfor %}
  </section>
</body>
</html>

Same binding, same template attribute; the template just emits a month heading whenever the month changes. Load /archive.html — both posts under "August 2026". When you one day write a September post, a new heading will appear on its own.

The feed

Here is the part that tends to surprise people: the feed is the same technique again, in XML. Create feed.xml at the top of the site:

<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:p="https://pagelove.org/1.0" xmlns:r="https://pagelove.org/Binding/CSS" p:template="text/liquid" r:posts="[itemtype='https://blog.example/Post']"><title>Field Notes</title><link href="https://field-notes.onpagelove.com/"/><link rel="self" href="https://field-notes.onpagelove.com/feed.xml"/><id>https://field-notes.onpagelove.com/</id>{% assign pub = posts | where: "status", "Published" | sort: "publishedAt" | reverse %}{% if pub.size > 0 %}<updated>{{ pub.first.publishedAt }}T00:00:00Z</updated>{% endif %}{% for post in pub %}<entry><title>{{ post.title | escape }}</title><link href="https://field-notes.onpagelove.com/posts/{{ post.slug }}.html"/><id>https://field-notes.onpagelove.com/posts/{{ post.slug }}.html</id><updated>{{ post.publishedAt }}T00:00:00Z</updated><published>{{ post.publishedAt }}T00:00:00Z</published><summary>{{ post.excerpt | escape }}</summary><author><name>{{ post.author | escape }}</name></author></entry>{% endfor %}</feed>

An XML file composes just like an HTML one: the binding gathers the posts, the template renders an Atom entry for each. Check it:

curl -s https://field-notes.onpagelove.com/feed.xml

You should see one <entry> per published post. Paste the feed address into a feed reader if you use one — your blog is now subscribable. To advertise it, add this line inside the <head> of index.html and archive.html:

<link rel="alternate" type="application/atom+xml" title="Field Notes" href="/feed.xml">

Letting readers comment

So far the public only reads. Comments mean letting strangers write to your site — on Pagelove that is not a scary sentence, because writes only happen where a rule permits them, and we will pin the permission to exactly one element.

First, give the post page a comment form. In posts/:slug.html, add this inside <main>, between the .post-missing block and the back link:

<form class="comment-form" id="comment-form">
  <h3>Leave a comment</h3>
  <input type="text" name="author" maxlength="80" placeholder="Your name" required>
  <textarea name="body" maxlength="2000" placeholder="Say something…" required></textarea>
  <button type="submit">Post comment</button>
</form>

Then add this script just before </body> on the same page:

<script type="module">
  const form = document.getElementById("comment-form");
  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const slug = location.pathname.match(/\/posts\/(.+)\.html$/)[1];

    const comment = document.createElement("li");
    comment.className = "comment";
    comment.setAttribute("itemscope", "");
    comment.setAttribute("itemtype", "https://blog.example/Comment");

    const meta = document.createElement("p");
    meta.className = "comment-meta";
    const author = document.createElement("strong");
    author.setAttribute("itemprop", "author");
    author.textContent = form.author.value.trim();
    const posted = document.createElement("time");
    posted.setAttribute("itemprop", "createdAt");
    posted.setAttribute("datetime", new Date().toISOString().slice(0, 10));
    posted.textContent = "just now";
    meta.append(author, " ", posted);

    const body = document.createElement("div");
    body.setAttribute("itemprop", "body");
    for (const para of form.body.value.trim().split(/\n{2,}/)) {
      const p = document.createElement("p");
      p.textContent = para;
      body.append(p);
    }

    comment.append(meta, body);

    const res = await fetch(location.pathname, {
      method: "POST",
      headers: { "Range": `selector=#comments-${slug}`, "Content-Type": "text/html" },
      body: comment.outerHTML,
    });
    if (res.ok) {
      document.getElementById(`comments-${slug}`).append(comment);
      form.reset();
    }
  });
</script>

We build the comment as real elements, not a string. Everything a visitor typed goes in through textContent, which the browser can never parse as markup — so nothing they write can become an element or an attribute, and there is no escaping code to get wrong. (A blank line in the comment starts a new paragraph.) The finished element is serialized once, with outerHTML, as the request body; on success we append the very same element to the page, so what the reader sees is exactly what was sent.

Look at where the POST goes: to the post page's own address, scoped by Range: selector= to the comments list. The comments list was stamped in from the data file — and this is the quiet superpower of stamping: a write to a stamped element routes through to where the element came from. The comment lands in data/posts/<slug>.html, permanently, with one request.

Now the permission. Create rules.html at the top of the site:

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>authorization rules</title></head>
<body>
  <!-- Anyone may add a comment to a post page's comments list. -->
  <div hidden itemscope itemtype="https://pagelove.org/AuthorizationRule">
    <meta itemprop="actor" content="*">
    <meta itemprop="resource" content="/posts/*">
    <meta itemprop="selector" content="[itemprop='comments']">
    <meta itemprop="method" content="POST">
    <meta itemprop="action" content="allow">
  </div>
</body>
</html>

This is the same kind of rule you met in your first application, with the same shape: anyone, one method, one selector. Nothing else on the site accepts a public write.

Visit a post and leave a comment. It appears at once — then reload the page. Still there: it was written into the post's data file, and the page composed it back in. Check data/posts/hello-world.html in your editor and you will find the comment sitting in the list.

Making comments safe to accept

We have just let anyone on the internet write HTML into our site, so let's be precise about what they may write. Try being an attacker for a moment, from your terminal:

curl -s -o /dev/null -w "%{http_code}\n" -X POST \
  -H 'Range: selector=#comments-hello-world' \
  -H 'Content-Type: text/html' \
  --data-binary '<li class="comment"><script>alert("gotcha")</script></li>' \
  https://field-notes.onpagelove.com/posts/hello-world.html

Right now that script lands in your data file. Let's close the door. Create constraints.html at the top of the site:

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>constraints</title></head>
<body>
  <div itemscope itemtype="https://pagelove.org/ShapeConstraint">
    <meta itemprop="resource" content="/data/posts/*">
    <meta itemprop="resource" content="/posts/*">
    <meta itemprop="selector" content="[itemprop='comments'][id][class]">
    <code itemprop="permit">li[class="comment"][itemscope][itemtype="https://blog.example/Comment"]</code>
    <code itemprop="permit">p[class="comment-meta"]</code>
    <code itemprop="permit">strong[itemprop="author"]</code>
    <code itemprop="permit">time[itemprop="createdAt"][datetime]</code>
    <code itemprop="permit">div[itemprop="body"]</code>
    <code itemprop="permit">p</code>
  </div>
</body>
</html>

A shape constraint declares what the comments list is allowed to contain, as a closed list of permits — one line per element the real comment form produces, down to which attributes each may carry. Anything a write would leave in the list that is not covered — a <script>, an <img>, an onclick, a stray class — is refused with 422 Unprocessable Entity, and the write never happens.

Run the attack again. This time: 422. Then delete the script comment the first attempt left in data/posts/hello-world.html, leave an honest comment through the form, and see it still sails through. The public can say anything they like — as text, in the shape of a comment, and nothing else.

Locking the data folder

One tidy-up. Readers should meet posts through the pages, not by browsing data/; and when you later add drafts, their raw files must not be readable early. Add these two rules to the <body> of rules.html:

  <!-- The public reads composed pages, never raw data files. -->
  <div hidden itemscope itemtype="https://pagelove.org/AuthorizationRule">
    <meta itemprop="actor" content="*">
    <meta itemprop="resource" content="/data/*">
    <meta itemprop="method" content="GET">
    <meta itemprop="action" content="deny">
  </div>
  <div hidden itemscope itemtype="https://pagelove.org/AuthorizationRule">
    <meta itemprop="actor" content="users">
    <meta itemprop="resource" content="/data/*">
    <meta itemprop="method" content="GET">
    <meta itemprop="action" content="allow">
  </div>

Open a private browser window and try /data/posts/hello-world.html — refused. Now load the home page in the same window: the post is still there. The server composes pages with its own authority, so a binding can gather data the visitor could never fetch directly. Signed-in users (you, in your editor) can still read the files. The users actor is a built-in group matching any signed-in person.

What you have built

Write one more post file, watch it appear on the home page, the archive, and the feed, and take stock of the machinery you now know how to use:

Where to go next