Creating Like Buttons for the Small Web

TL;DR

I built iine, private, accessible, free like buttons for the small/indie web.

I wanted to add reaction buttons to my posts. Claps, hearts, cheers, kudos, likes.

My requirements:

  • Anonymous: no need for readers to create an account/log in
  • Free (as in freedom and as in gratis)
  • No unnecessary processing/storing of reader’s data
  • Accessible: keyboard and screen reader friendly
  • Japanese name (naturally)
  • Extra: works without JavaScript

(I also wanted an excuse to build my first backend project.)

The result:

Back end

A friend suggested Supabase for the backend. The free tier offers 500MB of database storage. This limitation aligned my privacy and cost requirements: the less data I store, the farther I’ll be from the limit!

I decided the core of the project would be a single table with three columns:

origin_domain 🔑slug 🔑counter
example.com/blog/hello-world47
osc.garden/blog/nostalgia6
osc.garden/projects/iine15

For debugging, I added two more columns:

  • created_ts: when the row was created
  • updated_ts: when the row was last updated

Because this is my first time creating a project with a backend, and because I’m making it public, I was cautious; I added rate limiting.

For a like to be processed, the user be below the threshold of hourly requests.

Again, I don’t want to store unnecessary/private data, so the rate limiting table stores:

identifier_hash 🔑request_count
-823456789012345678915
123456789098765432142
-56789012345678901237
987654321012345678958

identifier_hash is a hash of the client IP + the current hour:

hashtext(client_ip || date_trunc('hour', now())::text)

The rate limiting table is truncated (emptied) on an hourly basis using the pg_cron extension:

select cron.schedule(
  'hourly-rate-limit-cleanup',
  '3 * * * *',
  'truncate table iine.rate_limits;'
);

I had no idea Postgres supported cron jobs! How cool is that?!

Front end

The idea is simple. On page load: detect the iine button and fetch likes count. On click: increase count locally (optimistic update), and call the endpoint that increments the count for that URL.

If JavaScript is disabled, we can’t fetch the count (not without server-side rendering). Instead, we have a form that submits to the same endpoint. It looks like:

<form action="https://e.supabase.co/rest/v1/rpc/increment_hits?apikey=key"
  method="post">
  <input type="hidden" name="page_slug" value="/your-page">
  <button type="submit" class="iine-button" aria-hidden="true">
    <noscript>♥️</noscript>
  </button>
</form>

The user-defined icon (in this example, ♥️) is only shown when JavaScript is disabled. Upon click, it calls the same endpoint with a POST request:

    flowchart LR
      A[iine button] --> B{JavaScript?}

      B -->|Yes| C[GET /get_hits]
      C --> D[Display: ♥️ 41]
      D --> E[Click]
      E --> F[POST /increment_hits]
      F --> G[Display: ♥️ 42
Button disabled] B -->|No| H[Display: ♥️] H --> I[Form submit] I --> J[POST /increment_hits] J --> K[Server response:
URL liked! ♥️]

That’s it! Just two pieces:

  • PostgreSQL with functions and endpoints
  • A way (~3KB JavaScript / HTML form) to call the endpoints

I named it iine (いいね), which means “that’s nice!” in Japanese, and it’s also the way people refer to the like buttons in general.

Since the free tier of Supabase can comfortably support iine buttons for 100,000+ sites (assuming 20-50 buttons per site), I decided to make it public and not require registration (I really don’t want your data). For anyone interested in self-hosting, here’s a guide.

I spent a weekend working on the backend + JavaScript. Half a day on accessibility, another half figuring out the progressive enhancement, and a few hours adding iine support to my theme, tabi. Happy to share it with the world!

Visit the website at iine.to and explore the code on GitHub.