The Big Problem With Simple Counting
You just want to know if people are actually reading your stuff, but then you look at Google Analytics or Plausible, and it feels like too much. It's this huge, heavy script just to get a small number. For a personal project, that felt like trying to hit a nail with a sledgehammer.
I wanted something simple, private, and fast. Above all, I wanted full ownership over how it was assembled and used.
From Browser to Database and Back
Here’s how the whole thing is wired up. The main goal was decoupling—I didn't want the counting logic to slow down the beautiful UI, so I split the job in half. The frontend's job is just to ask for the count, and the backend's job is to handle the count.
It's actually super simple:
Page Visit → TrackView.tsx (Hook) → POST [api]/views/[post_slug] → Postgres RPC → Atomic +1
The page stays fast, the count is always accurate, and it all feels intentional thanks to that engaging animation. It’s all possible because we made the counting and the displaying two independent requests.
Making the Numbers Feel Right
I wanted the view counter to look like it belonged. That classic terminal vibe or maybe an old mechanical counter. So, I threw in a couple of small details:
-
Leading Zeros: I made it so the count always shows at least three digits (like
007instead of just7). That simple change makes it feel way more like a mechanical odometer. -
Scramble Effect: I already had the scramble component built for something else, so I just reused it here and decided to justify it in hindsight! It takes the brief moment the database needs to load the number and turns it into a digital readout.
Side note: One day, I really want to build a proper split-flap counter effect. That would be a true tribute to those brilliant old mechanical designs.
Hey Future Me, Remember Why You Did This
If you're wondering why you wasted a weekend on this, remember this list. These are the real reasons you built this thing:
-
No Bloat: Say goodbye to slow-down. Eliminate external analytics scripts and heavy SDKs that compromise your Time to First Byte and overall page speed (LCP).
-
Learn Something New: This was your excuse to finally dig into SQL: functions, triggers, RLS. All the stuff you skip otherwise.
-
Privacy FTW: Maintain absolute control over your metrics. You store only what is essential (in this case, just the slug and the counter integer). Only store a slug and a count. Zero cookies, zero external surveillance.
Ditching the Race Condition
If you try to fetch the view count, add one in your JavaScript, and then save it back, you're setting yourself up for a race condition. If two people load the page at the same millisecond, you'll lose a count.
My solution? Upserts. I handed the full responsibility to the database. If the blog post slug is brand new, the database creates the row. If the row already exists, it uses a single, atomic command to increment the number. This guarantees integrity—no lost views, ever.
I know I'm not going to have crazy traffic, or probably even cross 999 views on a post. But the point here is to learn the idea, stay overprepared, and build that muscle for building robust architecture. Let the girl practice.
Protecting the Count (and the Code)
The flow is okay, but you always have to handle the details. I focused on two key areas for a solution:
-
Preventing Local Double-Dipping: During development, React's Strict Mode runs effects twice, which would instantly double-count my own views. I added a simple fix: a useRef flag to ensure the view tracking hook only fires once during local development.
-
Security Lockdown (RLS): The database is locked down with Row Level Security (RLS). Only the server-side API with the secret service role key is allowed to write or increment the count. Public or anonymous keys are strictly forbidden from performing any write action. No one can manually spam the counter.
| Operation | Role Required | Condition / Policy |
|---|---|---|
| INSERT/UPDATE (Writing) | Service Role | Only the authenticated server (using the secret key) is allowed to perform the atomic increment. |
| SELECT (Reading) | anon / Public | Any user or client can read the view count (public data). |
Ideally, I shouldn't count any views if they come from the dev mode environment. Now that this counter is tested and working, stopping local views entirely is a future task and issue to solve.
The Code
So, how did all these pieces—the fast UI, the secure database, and the scramble effect—actually come together? I followed the classic, boring-but-effective three-layer structure:
-
The Database Layer (Source of Truth): Postgres functions and RLS.
-
The API Layer (Gatekeeper): A single, protected endpoint.
-
The UI Layer (Presenter): The React components that do the reading and writing.
Here’s what the source tree looks like for the new bits:
We deliberately chose a dedicated <TrackView /> component instead of a custom hook. Hooks are great when you want to reuse logics like state, handlers, shared behavior. This wasn’t that. This was a one-off side effect whose entire job is to quietly fire a POST request when the page loads and then disappear.
Architecturally, the split is boring in the best way. <ViewCounter /> reads (GET). <TrackView /> writes (POST). They don’t talk, they don’t coordinate, and they don’t know each other exists. One reads, one writes, and both mind their own business.
The Data Layer
We don't just update a row; we use a stored function with SECURITY DEFINER. This means only our trusted, logged-in server can execute this update, making the database vault absolutely impenetrable to public requests.
The API Layer
We set Cache-Control: 'no-store' on the POST response to make sure we never accidentally cache the view count itself in the API layer. We always want the freshest count pulled straight from the database.
The UI Layer
The front-end component displays the count. It handles two things: fetching the initial count and then formatting it with a "scramble" effect (xxx placeholder) while it loads.
Beyond Views
Looking back at this simple view counter, the real achievement wasn't the number on the screen, but the architectural pattern we established. Clean separation between the Read (GET) and Write (POST) operations and relying on the database's atomic function. Adding new features now becomes an exercise in pattern replication:
-
If we wanted to track "Claps" or "Likes": The approach is identical. We don't need a new architecture, just a new table and a new atomic function. We'd simply reuse the secure POST request logic to hit a slightly different API endpoint (
[api]/views/[post_slug]) that calls the new database function. -
If we wanted a "Time-Spent-Reading" metric: We can leverage the controlled lifecycle of a dedicated component. We would reuse the concept of our TrackView component, but instead of firing a request on mount, the component would run a cleanup function on unmount, sending the total reading time to the API.
Now your turn! If you, the reader, build your own version of this system, please let me know. I'd genuinely love to see how you styled your metrics and which robust anti-race-condition tricks you implemented!