DEV Community

Cover image for A Simple Like System for a Static Blog
Isaac FEI
Isaac FEI

Posted on • Originally published at isaacfei.com

A Simple Like System for a Static Blog

I noticed my blog gets many daily visitors but very few comments. Maybe readers don't even notice the discussion section at the bottom. Or perhaps it's the friction: Giscus requires GitHub login, and writing a comment takes effort. I wanted a simpler way to get some reactions—a one-click signal that says "I liked this" without accounts or typing.

A like button fits that bill. The system itself is straightforward: one like per visitor per article, stored in a database. The catch: I needed a backend, and I didn't want to spin up a separate project just for this small feature. That's when I remembered I already run a playground project with TanStack Start—a full-stack app on Cloudflare Workers. I could add the like API there.

This post covers the motivation, design choices, and implementation. I use TanStack Start + Neon Postgres because that's what I have; you can use any tech stack. The ideas translate.

Try the button below. It's wired to the real API for this post.

Interactive or embedded content omitted — see the original post: https://isaacfei.com/posts/simple-like-system

Motivation

A static blog is fast and cheap to host, but it often feels one-sided—readers consume, there's little feedback. I wanted a lightweight way for readers to signal appreciation without signing up: anonymous, low stakes (likes don't change content), and resilient enough to work when IP headers are missing (local dev, some proxies).

The goal isn't to prevent all abuse; it's to make casual duplicate-liking annoying enough that most people won't bother, while keeping the experience smooth for everyone else.

The Idea

A floating bar at the bottom of each post: a heart button with a count, plus a shortcut to jump to the comment section. One like per visitor per article. The tricky part: who is a visitor? Without accounts, we need a stable, privacy-preserving identifier.

Design

Visitor Identification

This is the core of the implementation. The system must answer: is this request from (approximately) the same person who liked before? Without that, we can't enforce one like per visitor per article. We have no accounts—only two fallible signals: a client-stored UUID and the request's IP.

Two-tier design. We try the UUID first; if it's absent or invalid, we fall back to IP. Both get hashed before storage, so the DB only ever sees visitor_hash. The key is that the same identifier must always map to the same hash, so repeated requests from the same visitor hit the same row.

Mermaid diagram

Source 1: localStorage UUID. The frontend generates a UUID on first visit, stores it in localStorage, and sends it as X-Likes-Visitor-Id. Same browser, same device → same UUID across requests. Works when IP headers are missing (local dev, some proxies). User-controlled: clearing storage = fresh identity. No fingerprinting.

Source 2: IP fallback. When the header is absent (e.g. localStorage disabled, private mode) or invalid (spoofed), we use the client IP from X-Forwarded-For, CF-Connecting-IP, or similar. The server always has this on real requests. Same IP behind the same NAT → same identifier. IP changes (mobile network, VPN, DHCP) mean the system may treat the same person as "new"—acceptable for a lightweight like system.

Scenario Identifier used Result
Normal browser UUID in header Same visitor across requests
Private mode (no localStorage) IP fallback Same visitor if same IP
Dev/proxy (no IP headers) UUID Works when UUID present
All failed (no header, no IP) 400 missing_visitor_id

Impersonation guard. The visitor ID must be a valid UUID. If the header contains anything else (e.g. someone trying to inject an IP), we ignore it and fall back to the real IP. Prevents one visitor from spoofing another's identity.

Priority Source When used
1 X-Likes-Visitor-Id (UUID from localStorage) Header present and valid
2 Client IP from request headers No header, invalid header, or header spoofed

Hashing & Privacy

Raw IPs and visitor IDs are never stored. Before any DB write, we hash the identifier:

visitor_hash = SHA-256(salt + ":" + identifier)
Enter fullscreen mode Exit fullscreen mode
  • Salt — From LIKE_SYSTEM_SALT env var. Rotate if compromised.
  • Output — 64-char hex string. One-way; we can't recover the original.

The article_likes table stores (slug, visitor_hash) with a unique constraint on (slug, visitor_hash)—one like per visitor per article.

Architecture

Mermaid diagram

Frontend: Astro + React. FloatingReactionBar sits at the bottom of each post. Backend: TanStack Start on Cloudflare Workers, CORS configured for the blog domain. Database: Neon Postgres via Drizzle ORM.

API Contract

Method Endpoint Headers Response
GET /api/likes/:slug X-Likes-Visitor-Id (optional) { count, liked }
POST /api/likes/:slug X-Likes-Visitor-Id (optional) { count, liked }

Both require a visitor identifier (header or IP). If neither is available, the API returns 400 missing_visitor_id.

CORS

Blog and API live on different origins—the browser blocks cross-origin fetch unless the API returns CORS headers. Allow your frontend domain(s) in Access-Control-Allow-Origin, not mine. Custom headers (Content-Type, X-Likes-Visitor-Id) trigger a preflight OPTIONS; respond with 2xx plus Allow-Headers, Allow-Methods, and optionally Max-Age. My config: allowlist isaacfei.com, isaac-fei.com, localhost; reflect origin when matched.

Implementation

With the design in place, here's how to build it—from database to frontend.

Database Schema

// article_likes table
{
  slug: text,
  visitor_hash: text,  // SHA-256(salt + identifier)
  created_at: timestamptz
}
// UNIQUE(slug, visitor_hash)
Enter fullscreen mode Exit fullscreen mode

One row per (article, visitor). Unlike = delete row.

Visitor ID (Frontend)

// src/lib/likes-visitor-id.ts
const STORAGE_KEY = "likes-visitor-id";

export function getOrCreateLikesVisitorId(): string {
  if (typeof window === "undefined") return "";
  try {
    let id = localStorage.getItem(STORAGE_KEY);
    if (!id) {
      id = crypto.randomUUID();
      localStorage.setItem(STORAGE_KEY, id);
    }
    return id;
  } catch {
    return "";
  }
}
Enter fullscreen mode Exit fullscreen mode

Send it as X-Likes-Visitor-Id on every GET and POST.

Visitor Resolution (Backend)

// Prefer valid UUID from header, else IP
function getVisitorIdentifier(request: Request): string | null {
  const raw = request.headers.get("X-Likes-Visitor-Id")?.trim();
  if (raw && isValidLikesVisitorId(raw)) return raw;
  return getClientIp(request);
}
Enter fullscreen mode Exit fullscreen mode

Validate the UUID format before trusting it.

Hashing

async function hashVisitorIdentifier(identifier: string, salt: string): Promise<string> {
  const input = `${salt}:${identifier}`;
  const data = new TextEncoder().encode(input);
  const hash = await crypto.subtle.digest("SHA-256", data);
  return Array.from(new Uint8Array(hash))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}
Enter fullscreen mode Exit fullscreen mode

Toggle Like Logic

A POST toggles like/unlike: if the visitor has already liked, we remove the row (unlike); otherwise we insert (like). The DB enforces one row per (slug, visitor_hash), so the flow is simply: check existence → delete or insert → return updated count and liked state.

Mermaid diagram

Step Action Meaning
1 Get identifier Visitor ID or IP (see Visitor Identification)
2 Hash with salt visitor_hash; same identifier → same hash
3 Check if row exists SELECT where slug + visitor_hash match
4a Row exists User already liked → DELETE (unlike)
4b Row absent User hasn't liked → INSERT (like)
5 Return { count, liked } for the article

Environment Variables

Variable Required Description
DATABASE_URL Yes Neon Postgres connection string
LIKE_SYSTEM_SALT Yes 32+ char random string for hashing

UI: Fetch, Optimistic Updates, and Shared State

The frontend calls the API on mount and on like, then renders the result. Two approaches—simple and TanStack Query—each with different trade-offs.

Simple Approach

When you have a single like component, a straightforward implementation suffices. On mount, fetch the GET endpoint and store count and liked in state; on click, POST and update optimistically.

Optimistic updates make the UI feel instant: flip the heart and adjust the count immediately, before the server responds. If the request fails, revert in the catch block. The alternative (wait for the round-trip) feels laggy; users expect likes to register at once.

Guarding against rapid clicks matters: a double-tap can fire two POSTs before the first finishes. Use a posting (or inFlight) boolean: set it to true when the request starts, false when it completes. Ignore clicks with if (posting) return, or disable the button. An AbortController can cancel an in-flight request if the user clicks again—optional but useful.

Error handling: show a toast on failure, auto-dismiss after a few seconds. Add select-none on the button to avoid accidental text selection when tapping. This approach works well as long as you have only one like component on the page. Once you add a second (e.g. a demo in the content and a floating bar), you run into sync problems.

TanStack Query (Bonus)

When two or more components display the same like state—e.g. a demo button mid-article and a floating bar at the bottom—three problems appear:

  1. Stale data. Each component fetches independently. User likes via component A; component B still shows the old count until it refetches. Without coordination, the UI is inconsistent.
  2. Mutation signaling. When component A successfully POSTs, how does component B know to refetch? You could dispatch a custom event, lift state, or poll—each adds glue code and complexity.
  3. Shared server state. Both components display the same logical data. They need a single source of truth, not separate copies that can drift.

TanStack Query addresses all three. A shared cache key per (slug, apiBaseUrl) gives you a single source of truth: both components useQuery with the same key and read from the same cache. Mutations use useMutation; on success, setQueryData updates the cache, and every component subscribed to that query re-renders automatically. Optimistic updates go in onMutate; rollback in onError using the previous cache value. No events, no manual refetch signals—the cache is the coordination layer.

Astro islands caveat. Each client:load component is its own React root. A single QueryClientProvider in the layout does not wrap them; React context does not cross roots. The fix: use a shared QueryClient instance—a module-level singleton created once when the first island loads. Each island wraps itself with QueryClientProvider and passes that shared client. They remain separate roots but share the same cache.

Top comments (0)