Skip to content
Rezha Julio
Go back

Bringing Interactivity to a Static Blog: The Clap Button

4 min read

I love my Astro blog. It’s fast, cheap to host, and I don’t have to worry about security patches. But it’s also lonely. When I publish something, I have no idea if anyone actually read it, let alone liked it.

I didn’t want to install a heavy comment system like Disqus or utteranc.es just yet. I wanted something low-friction. The “Clap” button on Medium is perfect for this—it’s anonymous, satisfying to click, and gives me just enough signal to know if a post landed.

So I built one. The full code is on GitHub if you want to deploy your own.

The stack:

The Backend (Cloudflare Workers)

The code below is simplified for clarity. See the full implementation for slug validation, proper rate limiting, and configurable CORS.

I needed a place to store the counters. Cloudflare KV works well here—eventual consistency is fine for likes, and reads are fast.

I wrote a small API using Hono. It has two jobs: get the count, and increment it.

worker.js
import { Hono } from 'hono';
import { cors } from 'hono/cors';
const app = new Hono();
const MAX_CLAPS_PER_IP = 50;
// Security: Lock this down to your domain in production
app.use('/*', cors({
origin: (origin) => origin.endsWith('rezhajul.io') ? origin : null,
}));
app.post('/claps/:slug', async (c) => {
const { slug } = c.req.param();
const ip = c.req.header('CF-Connecting-IP');
// 1. Rate Limiting (Simplest implementation)
const rlKey = `rl:${ip}:${slug}`;
const rlData = await c.env.BLOG_CLAPS.get(rlKey, { type: 'json' }) || { count: 0 };
const body = await c.req.json().catch(() => ({ count: 1 }));
const increment = Math.min(body.count || 1, 10); // Batch limit
if (rlData.count + increment > MAX_CLAPS_PER_IP) {
return c.json({ error: 'Too many claps' }, 429);
}
// 2. Update Rate Limit
rlData.count += increment;
await c.env.BLOG_CLAPS.put(rlKey, JSON.stringify(rlData), { expirationTtl: 3600 }); // 1 hour TTL
// 3. Update Global Count
const key = `claps:${slug}`;
const current = await c.env.BLOG_CLAPS.get(key);
const next = parseInt(current || '0', 10) + increment;
await c.env.BLOG_CLAPS.put(key, next.toString());
return c.json({ count: next });
});
export default app;

I implemented a simple rate limiter using the user’s IP. You can clap up to 50 times per post per hour. This stops script kiddies from inflating the numbers while letting enthusiastic readers (like my mom) click away.

The Frontend (Preact Island)

For the button, I used Preact. It’s tiny (3KB) and I don’t need the full weight of React for a single button.

The tricky part is making it feel instant. I use optimistic UI updates—the number goes up immediately when you click, even before the server responds. I also debounce the network requests. If you click 10 times in one second, the browser only sends one request to the server saying “add 10”.

src/components/ClapButton.tsx
import { useState, useRef } from 'preact/hooks';
import confetti from 'canvas-confetti';
export default function ClapButton({ slug, apiUrl }) {
const [count, setCount] = useState(0);
const [userClaps, setUserClaps] = useState(0); // Track local session
const debounceTimer = useRef(null);
const pendingClaps = useRef(0);
const handleClap = () => {
if (userClaps >= 50) return;
// 1. Optimistic Update
setCount(c => c + 1);
setUserClaps(c => c + 1);
pendingClaps.current += 1;
// 2. Confetti! 🎉
confetti({
particleCount: 15,
spread: 40,
origin: { y: 0.7 },
colors: ['#FFD700', '#FFA500']
});
// 3. Debounce API Call
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(async () => {
const payload = { count: pendingClaps.current };
pendingClaps.current = 0;
await fetch(`${apiUrl}/claps/${slug}`, {
method: 'POST',
body: JSON.stringify(payload)
});
}, 1000);
};
return (
<button onClick={handleClap} disabled={userClaps >= 50}>
<span className="text-2xl">👏</span>
<span className="font-bold">+{userClaps}</span>
</button>
);
}

Integrating into Astro

Astro makes this trivial. I just drop the component into my layout and tell Astro to hydrate it when it becomes visible.

src/layouts/PostDetails.astro
<div class="my-8 flex justify-between">
<ShareLinks />
<ClapButton slug={slug} client:visible />
</div>

The client:visible directive matters. If a user never scrolls down to the bottom of the article, the JavaScript for this button never loads.

Why This Matters

It’s a small feature, but it closes the loop. Writing into the void is hard. Seeing a counter go up—even by one—is a nice reminder that there’s a human on the other side of the screen.

Go ahead, try it out below. 👇


Related Posts


Next Post
Why "Dumb" Agents Are Winning: The Case for Shell-First AI