In my last post, I wrote that I've started writing about music practice on note, a Japanese blogging service. On note, when you press the "Suki" (like) button on a post, a speech bubble pops up with a thank-you image and message chosen by the writer. For the thank-you on note, I made a GIF animation of Friko, the mascot, hopping along to a 120 BPM rhythm.
You can press Suki on note even if you're not logged in. Readers can say "I liked this" without creating an account.
Now this dev blog does the same. There's a like button at the end of each post. You can press it without signing in, and press it again to take your like back.

- The like button
Liking a post shows the same thank-you as on note
When you press like, a speech bubble appears above the button. It shows the same GIF as the thank-you on note.

The message plays on the GIF's tempo, 120 BPM: "Got your like at 120 BPM! Thank you ♪".
Likes are counted in BPM
The number of likes is shown in BPM, like "12 BPM". One like is 1 BPM. It's a playful idea: the more likes a post gets, the faster its tempo. It doesn't measure an actual tempo. The Japanese and English versions of a post share one like count.
Keeping one person from liking over and over
If people can like without signing in, you need to stop the same person from liking again and again. When note started allowing Suki without logging in, its announcement said the same person can't press Suki repeatedly (note's official announcement, in Japanese). But it doesn't say how they tell people apart. So here is what this blog does:
- People who are signed in are identified by their account
- People who aren't signed in are identified by a random ID stored in their browser (a cookie)
The database stores one row per "post × person", and it can't hold two rows for the same pair. Here are the key lines from the schema for Prisma, the library I use to work with the database:
model BlogPostLike {
postSlug String
userId String?
guestId String?
@@unique([postSlug, userId])
@@unique([postSlug, guestId])
}Only one of userId (the account) and guestId (the ID stored in the browser) can be set, enforced by a CHECK constraint in the database. This keeps a single like from being counted under both the account and the browser. @@unique doesn't treat nulls as duplicates, so guest rows with an empty userId never collide on the userId side.
That said, you can like again if you clear your cookies, or if you like without signing in and then sign in, because each of those counts as a different person. A like is a light reaction, so I decided not to block those cases. Instead, liking and unliking are limited per IP address (60 times an hour), which stops anyone from inflating the count on a large scale.
The ID is set when you press like
The ID that tells apart people who aren't signed in is the same one the Metronome.fm app already uses. To count daily playback time for people who aren't signed in, the app sets this ID as a cookie for anyone who opens an app page. It's set by the code that runs before a request reaches the page (Next.js's proxy, formerly middleware). Likes reuse how this ID tells people apart, but set it at a different time.
On the blog, the ID is set the first time you press like, not when you open a post. That way, people who only read don't get a cookie that tells them apart. Post pages also get a cookie that remembers your language (NEXT_LOCALE), but that's a display setting, not something that tells people apart.
There's an exception: the ID can still be set even if you only read a post. Below each post there's a link that opens the app, and when a link comes into view, Next.js loads the page it points to in advance (prefetching). That prefetch is also a request for an app page, so the proxy adds the ID cookie to the response and the browser stores it. I tried to skip the cookie for prefetches only, but Next.js removes the headers that show whether a request is a prefetch before passing it to the proxy, so I couldn't tell them apart. I chose to keep prefetching so that moving to the app stays fast.
Speed turned out not to be a reason
At first, I also gave speed as a reason not to set the ID when a post opens. Posts are built ahead of time and served from Vercel's CDN (delivery servers around the world). The number of likes and whether you've liked the post are fetched by the browser after the page opens, so the page itself is the same for everyone. One of the conditions for the CDN to cache a response is that it has no set-cookie header (Vercel's documentation), so I assumed that setting a cookie when a post opens would stop the page from being cached.
But when I checked in production after publishing, post pages carrying the NEXT_LOCALE cookie were being served from the CDN cache (x-vercel-cache: HIT). Even though they came from the cache, the cookie's expiry time changed on every request. The CDN caches the page body, and the proxy adds the cookie to each response afterward. Since the ID is also set by the proxy, setting it when a post opens wouldn't have slowed the page down.
Stopping other sites from sending likes for you
Another site could hide a form or JavaScript that makes a reader's browser send a like request here (this is called CSRF). The reader's browser has the ID cookie, so it would count as that reader's like. The like API blocks this in two ways:
- It only accepts requests sent as JSON. A form can't send JSON, so it stops here. If JavaScript on another site tries to send JSON, the browser first asks whether that's allowed (CORS), and since I don't allow it, the request stops there
- It checks the
Sec-Fetch-Siteheader that browsers add, and rejects requests that don't come from the same origin (same-origin)
const isSameOriginJsonRequest = (request: NextRequest) => {
const contentType = request.headers.get("content-type") ?? "";
const fetchSite = request.headers.get("sec-fetch-site");
return (
contentType.startsWith("application/json") &&
(fetchSite === null || fetchSite === "same-origin")
);
};Requests without Sec-Fetch-Site are let through because they come from non-browser clients or older browsers. Non-browser clients can't use a reader's cookies, and older browsers still stop cross-site JSON with CORS.
If you enjoyed a post, I'd be glad if you pressed the like button at the end. And if you press Suki on my note posts, the same Friko comes out to say thanks.