← Blog

Feedback buttons without JavaScript, using a 1990s web pattern

3,162 words Filed in: web development, static sites, Cloudflare Workers, retro web

Illustration of a 1990s desktop PC with CRT monitor showing a browser window, beside a smaller screen displaying a hit counter, in a retro poster style.
A 1990s desktop and its hit counter -- the original feedback system. Image made with Loras.dev.

A Cloudflare Worker is just a CGI script, and that's exactly what a static site needs.

I wanted to add a "Was this useful?" button to my blog posts. The site runs on GitHub Pages. No backend. No database. No server-side anything. And I didn't want JavaScript in the interaction itself.

The solution turned out to be three decades(!) old.

tl;dr

  • Skip to the setup tutorial
  • A Cloudflare Worker handles feedback clicks via HTTP redirect — no client-side JS needed
  • The pattern is identical to how 1994 web rings counted click-throughs
  • CSS :target shows a "Thanks!" message after the redirect without page navigation
  • An SVG endpoint returns just the number — the 1996 hit counter, reborn
  • The Worker source is in the worker/ directory of this site's repo

Constraints as a design tool#

This site already uses a CSS-only toggle to disable all styles with a checkbox. The search form works without JavaScript. URLs carry state instead of client-side stores. The pattern is clear: HTML and CSS do the work; JavaScript is an enhancement, not a dependency.

That's not accidental — it's why the site runs on Eleventy, a static site generator that ships zero client-side JavaScript by default. The framework's position is that HTML is the deliverable; everything else is opt-in.

Feedback buttons should follow the same rule. Click a link, register the vote, done. No fetch() calls, no framework, no hydration. If JavaScript fails to load, the feedback still works.

The problem is obvious: the site is static HTML, so where do we put the brain?

What would a 1998 webmaster do?#

In 1998, your personal page on GeoCities was static HTML — just like GitHub Pages today. You had no backend. But you wanted interactivity: hit counters, guestbooks, polls. So you used other people's CGI scripts — small programs that ran on a web server, handled one HTTP request, and returned a response. An <img> tag could point to a CGI script on someone else's server that generated a counter image. A <form> action could submit to a remote script that appended to a flat file and redirected back.

The web was always a composition of origins. Your static page was the frontend; the internet was the backend.

Web rings used this exact architecture. The "Next site" button was a link to a central CGI script:

http://www.webring.org/cgi-bin/webring?ring=navships;id=002;next

Click it, and the script would increment a counter, look up the next site, and return a 302 Found redirect. The user barely noticed the hop. This pattern worked in Mosaic. It still works in every browser today. It's pure HTTP.

Replace "next site in the ring" with "the post you just read" and "click-through count" with "thumbs-up count." The architecture is identical.

The redirect-count-redirect pattern#

Here's what happens when a reader clicks the thumbs-up button:

Reader clicks "👍 Yes" on a blog post
  → GET https://feedback.allaboutken.com/up/posts/my-post/
  → Worker increments counter in KV storage
  → Worker returns: 302 Location: https://www.allaboutken.com/posts/my-post/#thanks
  → Browser follows redirect back to the same post
  → CSS :target reveals "Thanks for the feedback!" message
  → Reader never truly left the page

The #thanks fragment in the redirect URL is doing real work. It's URL-as-state again — the fragment triggers a :target CSS rule that reveals a thank-you message that was always in the HTML, just hidden. No JavaScript needed to show it.

The Worker itself is under 200 lines. It handles three route families:

  • /up/posts/slug/ — increment a KV counter, 302 redirect back to the post
  • /count/posts/slug.svg — return the count as an SVG number (image/svg+xml, 2-second cache). Drop it in an <img> tag. Add ?color=white for light text on dark backgrounds; defaults to black. Not CSS-styleable from the page since it's a separate document.
  • /count/posts/slug/ — return the count as plain text (text/plain, 2-second cache). Use this for build-time fetches, a small JS enhancement that injects the number into a <span> you can style, or scripting.

The SVG endpoint is the 1996 hit counter pattern. An <img> tag pointing to a script that returns a dynamically generated image:

<img src="https://feedback.allaboutken.com/count/posts/my-post.svg" alt="47 people found this useful">

The browser thinks it's loading a normal image. The Worker is secretly running code. This is exactly what <img src="/cgi-bin/counter.pl"> did in 1996, except the "CGI script" runs on a global edge network instead of a Pentium II under someone's desk.

Aside: The current implementation tracks positive feedback only — a single "Yes, this was useful" button. The Worker supports /down/ routes too, but the UI doesn't expose them yet. Binary up/down adds complexity (what does the count mean? net? total?) without much signal for a personal blog. A future version may add star ratings via ISMAP (see What's next at the end of this post).

Demo

A plain <a> link. No JavaScript. The Worker handles the rest. aria-live="polite" on the thank-you message announces it to screen readers when it appears.

👍 Yes, this was useful

<a href="https://feedback.allaboutken.com/up/posts/20260220-feedback-buttons-cgi-pattern/" rel="nofollow">👍 Yes, this was useful</a>
<span id="thanks" class="kh-feedback-thanks" aria-live="polite">Thanks for the feedback!</span>

SVG count (no JavaScript)

The SVG returns just the number. Set height:1.5em and it sits inline with text at any font size. Add ?color=white for dark backgrounds.

Feedback count people found this useful   Feedback count

Because the height is in em, it scales with the surrounding text:

Small: Feedback count people

Large: Feedback count people


Plain text count

Returns just the number. Use for build-time fetches, shell scripts, or data export.

curl https://feedback.allaboutken.com/count/posts/20260220-feedback-buttons-cgi-pattern/
# → a number

Styled with a JS enhancement

If you're OK with a bit of JS, fetch the plain text count and inject it into a <span> you control.

... people found this useful

<span id="js-feedback-count">...</span> people found this useful
<script>
fetch('https://feedback.allaboutken.com/count/posts/20260220-feedback-buttons-cgi-pattern/')
  .then(r => r.text())
  .then(n => {
    document.getElementById('js-feedback-count').textContent = n.trim();
  });
</script>

Musing: Cloudflare Workers are the new CGI script#

The idea here keeps showing up: old patterns, new infrastructure. A Cloudflare Worker runs on someone else's server, handles one HTTP request, reads/writes a small data store, and returns a response. The only differences from 1998:

  • It runs on a global edge network instead of a single machine
  • The "flat file" is a KV store instead of /var/www/data/counter.txt
  • The free tier is 100,000 requests/day instead of "whatever the sysadmin allows"
  • You deploy via wrangler deploy instead of FTPing a Perl script to /cgi-bin/

The fundamental patterns haven't changed. The infrastructure got better. If you need interactivity on a static site, think like it's 1998 and point your links at someone else's server. That server just happens to be a globally distributed edge network now.

The Worker source code is in this site's repo — under 200 lines of JavaScript, no dependencies beyond Wrangler. The pattern works for anything: polls, reactions, RSVP counters, hit counters. Sage Weil built it in 1994. I'm just running it on better hardware.

Setting this up on your own site#

You'll need to deploy your own Worker — you can't point your feedback links at mine. The Worker hardcodes a redirect back to a single site origin, so it's one Worker per site by design.

Required: a free Cloudflare account. Suggested: a domain on Cloudflare DNS is needed for custom domains; without it, you'll use the default workers.dev URL.

1. Copy and edit the Worker. Grab my worker/ directory (three files). The comments in wrangler.toml list everything you need to change, but in short:

  • SITE_ORIGIN in src/index.js — your site's URL
  • pattern in wrangler.toml — your subdomain (e.g. feedback.yourdomain.com)
  • id under [[kv_namespaces]] in wrangler.toml — replace with your own namespace ID (step 3)

If your domain isn't on Cloudflare DNS, remove the [[routes]] block entirely. Your Worker will be available at feedback-worker.<your-account>.workers.dev instead.

2. Install and authenticate. Wrangler is Cloudflare's CLI for deploying and managing Workers. npm install pulls it in as the only dependency.

cd worker
npm install
npx wrangler login

wrangler login opens a browser window for Cloudflare OAuth.

3. Create a KV namespace. This is the data store for vote counts.

npx wrangler kv namespace create FEEDBACK_COUNTS

Wrangler prints a namespace ID. Paste it into wrangler.toml as the id value, replacing the one from the repo.

4. Deploy.

npx wrangler deploy

If you have a [[routes]] block with a custom domain, the deploy provisions the DNS record automatically. You should see your Worker appear in the Cloudflare dashboard under Workers & Pages. If the custom domain doesn't provision on deploy, add it manually: Workers & Pages → your worker → SettingsDomains & Routes.

5. Test it.

curl -v https://feedback.yourdomain.com/up/posts/test/

You should get a 302 redirect back to your site. If you're using the default workers.dev URL, substitute that.

  • If you get a "could not resolve host" error on a custom domain, DNS may still be propagating — flush your local cache (sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder on macOS) and try again.

6. Add the HTML to your templates.

<a href="https://feedback.yourdomain.com/up/posts/my-post/"
   rel="nofollow">👍 Yes, this was useful</a>
<p id="thanks" style="display:none">Thanks for the feedback!</p>

And the CSS:

#thanks:target { display: block; }

rel="nofollow" keeps crawlers from following the links. The Worker handles bot filtering, rate limiting, and the redirect.

7. CI/CD (optional). The Worker is unlikely to change often — npx wrangler deploy from your machine is fine for occasional updates. If you do want automated deploys, add a GitHub Actions workflow that runs wrangler deploy when worker/** changes. Two repository secrets needed:

  • CLOUDFLARE_API_TOKEN — create via Cloudflare dashboard → API Tokens → "Edit Cloudflare Workers" template
  • CLOUDFLARE_ACCOUNT_ID — visible on your Cloudflare dashboard overview

Free tier limits: 100,000 Worker requests/day, 1,000 KV writes/day. The writes are the constraint — 1,000 feedback clicks per day, more than enough for most sites.

What's next#

Star ratings via ISMAP. The current implementation is positive-only — a single "Yes, this was useful" button. Star ratings are the natural next step, and the approach is too good not to mention: HTML's server-side image maps. Wrap an <img> in an <a> with the ismap attribute, and clicking the image appends ?x,y coordinates to the URL. The Worker calculates which star from the X coordinate. Server-side image maps were first supported in NCSA Mosaic 1.1 (1993). They still work in every browser.

<a href="https://feedback.allaboutken.com/rate/posts/my-post/">
  <img src="/images/five-stars-empty.svg" ismap
       alt="Rate this post from 1 to 5 stars">
</a>

A popularity index from my own data. Because the Worker and KV namespace are mine, I can crawl every counter at build time and rank posts by feedback count. An Eleventy data file that calls wrangler kv key list or hits the /count/ endpoints, sorts the results, and generates a "most useful posts" page — no third-party analytics dependency, no JavaScript on the client. The data is already there; it just needs a build step.

A community web ring for Eleventy sites. The architecture generalizes. If multiple Eleventy sites each ran their own feedback Worker, a shared index could aggregate the counts — "top rated Eleventy posts this month" in the spirit of a classic web ring. Each site keeps its own data, the index just reads the public /count/ endpoints. The pattern is cooperative, not centralized: no shared database, no sign-up, just HTTP.

Behind the decisions#

Privacy by design

No consent banner needed. The Worker increments a counter in KV storage — no IP addresses stored, no User-Agent logged, no cookies set. GoatCounter's GDPR analysis applies here with even more confidence: if aggregate-only page view counting falls under legitimate interest, then an anonymous integer counter certainly does.

Rate-limiting uses a SHA-256 hash of the IP address, stored as a KV key with a 24-hour TTL. The hash is not reversible and expires automatically. No persistent identifier is created.

Keeping bots honest

The obvious concern: what stops a crawler from wandering onto feedback.allaboutken.com/up/posts/my-post/ and registering a false vote?

Five layers of defense, from prevention to cleanup:

  1. Don't advertise the paths. Feedback links carry rel="nofollow", so search engines won't follow them from the post to the Worker.
  2. Return nothing indexable. Vote URLs return 302 redirects — no HTML, no content for a crawler to store. Count endpoints return raw SVG or plain text, not pages.
  3. Server-side filtering. The Worker checks User-Agent against known bot signatures and validates request headers. Simple crawlers don't send Referer or Accept-Language headers that real browsers do.
  4. Rate limiting. One vote per IP per post per 24 hours, enforced by the Worker via KV TTL keys. A SHA-256 hash of the IP and path is stored with a 24-hour expiration — if it exists, the redirect still works but the counter doesn't increment.
  5. Data cleanup. KV counters can be reset with wrangler kv key delete. If a bot swarm hits, zero the affected counters and move on.

An earlier design used GoatCounter page views as the counting mechanism, with a <noscript> tracking pixel as a JS-disabled fallback. That pixel is actually a bot magnet — simple crawlers load images but skip JavaScript, so the pixel counts bots while the JS-based counter filters them out. The Worker approach sidesteps this entirely: counting happens server-side, before any HTML is returned.

Standing on shoulders

I'm not the first person to think of this. Two projects proved different halves of the solution:

Andrew Walpole built a like button for his Eleventy blog using Cloudflare Workers and KV storage. He deployed it, ran it in production, and wrote about the experience. It proved the Eleventy + Workers + KV infrastructure works. But his approach uses client-side JavaScript — petite-vue renders the button, and fetch() API calls communicate with the Worker.

poll.fizzy.wtf is a Cloudflare Worker (written in Rust, MIT-licensed) that implements the redirect-count-redirect pattern with SVG result widgets. It's the 1994 web ring architecture applied to voting. No client-side JavaScript. Here's what using it looks like:

<a href="https://poll.fizzy.wtf/vote?allaboutken-feedback-post.standing-on-shoulders=yes&redirect=https://www.allaboutken.com/posts/20260220-feedback-buttons-cgi-pattern/%23standing-on-shoulders" rel="nofollow" class="kh-button kh-button--sm">👍 Yes <img src="https://poll.fizzy.wtf/count?allaboutken-feedback-post.standing-on-shoulders=yes" alt="Yes votes" style="display:inline;vertical-align:middle"></a>
<a href="https://poll.fizzy.wtf/vote?allaboutken-feedback-post.standing-on-shoulders=no&redirect=https://www.allaboutken.com/posts/20260220-feedback-buttons-cgi-pattern/%23standing-on-shoulders" rel="nofollow" class="kh-button kh-button--sm">👎 Not really <img src="https://poll.fizzy.wtf/count?allaboutken-feedback-post.standing-on-shoulders=no" alt="No votes" style="display:inline;vertical-align:middle"></a>

Try poll.fizzy.wtf live — was this section of the post clear?

👍 Yes Yes votes 👎 Not really No votes

Why not just use poll.fizzy.wtf directly? You could — and if you want the quickest path to feedback buttons, you probably should. It's MIT-licensed and ready to deploy. I built my own because I wanted a few things it doesn't do:

  • No cookies. poll.fizzy.wtf deduplicates votes with cookies. My Worker uses a hashed IP with a 24-hour TTL — no cookies set, ever.
  • Bot filtering. poll.fizzy.wtf trusts all requests. My Worker checks User-Agent, Accept-Language, and Referer headers before counting.
  • Single-tenant. My Worker hardcodes one site origin and rejects requests referred from anywhere else. poll.fizzy.wtf is designed as a shared service.
  • Data ownership. My KV namespace, my data. I can list all keys with wrangler kv key list, export counts to CSV, or wipe individual counters. With poll.fizzy.wtf the data lives on someone else's KV store.

On the flip side, poll.fizzy.wtf does something mine doesn't: arbitrary key-value voting. Its ?key=value query parameter approach lets you create any poll on the fly — multiple questions, multiple answers, no code changes. My Worker only counts one signal per post (positive feedback). That's a deliberate scope constraint for now, but if you need flexible multi-question polls, poll.fizzy.wtf has the better architecture for it.

If those tradeoffs don't matter to you, poll.fizzy.wtf is a perfectly good choice and saves you the setup.

Approaches I considered but didn't choose

The Worker wasn't the first idea. The design process explored five approaches. The ones I passed on are interesting because they reveal the design space.

GoatCounter page-view counting. The most obvious approach: feedback links go to static thank-you pages generated by Eleventy, and GoatCounter (already on the site) counts the page view. The URL path encodes the vote — /feedback/up/posts/my-post/ is a thumbs-up. Zero new dependencies. But it navigates the reader away from the post, the data mixes with analytics noise, and there's no way to show counts without a build-time API call. The Worker wins on UX, data quality, and bot resistance.

CSS :target with a tracking pixel. The most technically creative approach. Anchor links trigger :target, which reveals a hidden <span> whose background-image points to a GoatCounter counting pixel. The browser loads the image when the element becomes visible — CSS as an analytics transport. The problem: browsers may skip background-image loads on previously-hidden elements, especially with display: none to display: block transitions. It's fragile to test, unreliable across browsers, and raises accessibility concerns since screen readers may not announce the state change. Fascinating, but too brittle for production.

Mastodon retoots and favourites. The Eleventy community has a well-established pattern here: publish a post, toot the link, then pull Mastodon interactions back into the site via the Mastodon API or Webmentions with Bridgy. Boosts and favourites become your feedback signal; replies become comments. Max Böck, Sia Karamalegos, and others have written detailed implementations. It's a real solution and I may add it later as a complementary signal. But it's not feedback on the post — it's feedback on the toot. Readers who don't use Mastodon can't participate, and the signal measures social reach more than content usefulness. The Worker captures a direct "was this useful?" from any reader, regardless of what social networks they use.

External form services. Formspree, Basin, Google Forms — a <form method="POST"> pointing at a third-party service. True form submission, data stored somewhere you control (sort of). But it's a new SaaS dependency, the free tiers have submission limits, it redirects to a third-party confirmation page, and it runs against this site's self-hosted philosophy. Cloudflare Workers is technically also "someone else's server," but the code is ours, the data is ours, and the free tier is generous enough to be effectively permanent for a personal blog.

The feedback buttons are live on this post — scroll down and try them. If you set this up on your own site, I'd genuinely like to hear how it goes.


Further reading on the web patterns mentioned here:

Related posts: