Likes and Guestbook
A look at how likes and guestbook messages are implemented with Astro and Cloudflare
This blog is built with Astro, and most of its pages are static files. That works very well for publishing content, but there are two small parts that need to receive information from visitors: the like button and the guestbook form. To add this interactivity without turning the whole site into a dynamic app, I keep the UI inside Astro components and use a Cloudflare Worker only for routes that start with /api/.
The likes
Each post has a permanent identifier, for example post:likes-and-guestbook. The page passes this value to component, which makes a request like this:
GET /api/likes/post:likes-and-guestbook
When the page loads, the component sends a GET request to get the current number of likes and check whether that visitor has already liked the content. If they click it, it sends a POST request to the same URL. The response has a very simple structure:
{
"contentId": "post:likes-and-guestbook",
"likes": 12,
"liked": true
}
I do not use any extra framework in the browser. The component script updates the icon and counter. The persistent data is stored in a D1 database. The table uses a composite primary key made from content_id and visitor_id, so the same combination cannot be stored twice:
CREATE TABLE likes (
content_id TEXT NOT NULL,
visitor_id TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
PRIMARY KEY (content_id, visitor_id)
);
The Worker identifies the visitor with a cookie that contains a random UUID and lasts for one year. When the Worker receives the POST request, it runs INSERT OR IGNORE. This means that even if there is a double click or the request is repeated, the counter only increases once for that identifier
This is not meant to be an identity system or a perfect protection against abuse. If someone deletes their cookies, they will get a new identifier. For an informal like counter, I think this is a reasonable balance: there are no accounts, I do not store email addresses, and I do not need to track the person
The guestbook
The guestbook has different needs. I do not want every submitted message to appear automatically on a public page, and I also do not want to maintain a small admin application just for approving messages. When the form is submitted, the browser converts the data to JSON and sends this request:
POST /api/comments/guestbook
If the data is valid, the message is not inserted into D1. Instead, the Worker uses the Cloudflare Email Sending binding to send it to a dedicated private inbox. If the visitor provided an email address I can reply directly without making their email public.
Publishing is intentionally manual. I review the email and, if the message is appropriate, I add it into guestbook page. That file remains the source of truth for the messages that are publicly visible. This flow is less automatic, but it removes the need for a moderation panel and prevents spam from appearing directly on the site.
Dynamic layer
The result is a blog that stays static where it makes sense, with a very small dynamic layer:
- Astro generates the pages
- The Worker routes and validates API requests
- D1 stores only the likes
- Email Sending delivers guestbook messages for moderation
- The repository keeps the published comments
I like this approach because each type of data is stored where it fits best. Likes need a counter and can work with a simple anonymous identity. Comments need a human decision before they become public. There is no reason for both systems to use the same kind of storage just because they are both interactions on the blog.