
A static Astro blog that counts likes — one Vercel route and Upstash Redis
Adding a shared like counter to a fully static Astro site: one non-prerendered route, Upstash Redis key design, and the CI bugs that verifying it cost.
On this page
Introduction
I wanted a like button on my articles. My first instinct was that one button could do the work of two, since a like and a bookmark both amount to “this was worth something”, and that for a blog with no accounts the natural place to keep that was localStorage.
The agent disagreed with both halves, and its argument for the second one was the useful part: a like stored only in the reader’s browser tells nobody anything. The reader already has bookmarks, so they gain nothing they did not have. I learn nothing at all, which was the whole reason I wanted the button. So I chose the version that costs something: a real shared counter, which meant putting a server behind a site that had never had one.
This article covers the design argument that made the counter worth building, what a single serverless route actually costs a static site, and the three CI bugs it took before anything could prove the endpoint worked.
A count with no vote record is not a count
The obvious endpoint increments a number. That version is emptied of meaning by one curl loop, and the number under an article stops being evidence of anything.
So each like writes two records instead of one:
const countKey = (slug: string) => `${KEY_PREFIX}likes:${slug}`;const voteKey = (slug: string, voter: string) => `${KEY_PREFIX}likes:by:${slug}:${voter}`;The second one is what makes the first mean something. It records that a given caller has an active like, keyed by a SHA-256 hash of their address salted with a secret that never leaves the deployment, and it deletes itself after thirty days.
The TTL took some thinking. Permanent is wrong, because home connections get reassigned and a permanent record silently locks out whoever inherits the address. Twenty-four hours is also wrong: it lets the same reader like an article once a day forever, which is the same as not counting. Thirty days is the compromise, and it is stated on the site’s privacy page rather than left implicit.
The vote record buys a second thing that was not the goal. Because the server knows whether a like is active, it can be taken back:
if (liked) { // `nx` is what makes this idempotent: it writes only if no vote exists, // and returns null when one already did. const claimed = await store.set(vote, 1, { nx: true, ex: VOTE_TTL_SECONDS }); if (claimed === null) return { count: await readCount(slug), liked: true }; return { count: await store.incr(countKey(slug)), liked: true };}That nx is doing real work. Two tabs, a double tap, or a retry after a dropped response all land on the claimed === null branch and report the true state without moving the tally. And because unlike exists, the button can carry aria-pressed honestly instead of being a one-way action wearing toggle semantics.
What one serverless route costs a static site
Astro stays on output: 'static'. Every page is still prerendered; the adapter exists so that exactly one file can opt out.
export const prerender = false;The slug is checked against an allowlist built from getCollection('blog'), so a caller cannot invent keys and fill the database with them. Reads are cached at the CDN for a minute, which keeps the common case off the function entirely.
The route was the cheap part. Adding @astrojs/vercel moved the build output from dist/ to .vercel/output/static/, and two existing steps had the old path baked in. That failure is silent in the worst way: pagefind --site <missing> writes an empty index and exits 0, so the build stays green and ships a search box that finds nothing.
"build": "astro build && pagefind --site .vercel/output/static && pnpm run pagefind:patch","preview": "pnpm dlx serve .vercel/output/static",The preview line is the second cost. astro preview does not work with an adapter at all, so the script now serves the built directory directly. The Japanese search patch that runs after Pagefind was repointed too, and it gained an explicit existence check so a wrong path fails loudly rather than reporting a bare ENOENT.
Provisioning Upstash, and three choices in one dialog
Upstash for Redis is installed from the Vercel Marketplace rather than set up separately, which means the credentials are injected into the project instead of pasted anywhere.

The connect dialog asks three questions, and each of them has a consequence worth knowing before clicking.

Environments decides which deployments get the credentials. Production and Preview are checked; Development is not, which is deliberate. Without credentials the endpoint falls back to an in-memory counter and prints a warning, so pnpm dev needs no secrets at all.
Custom Prefix renames the injected variables. Leaving it blank is right when the code names them explicitly, and a prefix would only guarantee a mismatch that shows up as a 503 in production and nowhere else.
Sensitive stores the values in an unreadable form. That is correct for production hygiene and it has a consequence: the values cannot be read back afterwards by the dashboard, the API, or vercel env pull. Anything that needs them later needs a rotation instead.

Only KV_REST_API_URL and KV_REST_API_TOKEN are used. KV_URL and REDIS_URL are TCP connection strings for a conventional client, and a serverless function cannot hold a TCP pool, so this talks to Redis over REST.
The free tier is 500,000 commands a month against 256 MB, and Vercel’s Hobby plan allows a million function invocations. Neither is close for a personal blog.
Region is one decision, not two
Vercel defaults functions to iad1 in Washington, and the Hobby plan gets a single region. Upstash asks for a primary region at creation and that choice cannot be changed afterwards.
Left alone, those two defaults produce the worst arrangement available: a like from a reader in Japan crosses the Pacific to reach the function, then crosses back to reach a Tokyo database. I picked Tokyo for both, since two of this site’s three locales are Japanese and Traditional Chinese.
{ "regions": ["hnd1"] }Vercel names its regions after the AWS ones, so hnd1 and Upstash’s ap-northeast-1 are the same datacentre.
One database, two keyspaces
The integration connects the database to Production and Preview alike, which means a like clicked while testing a branch would move the number under a published article.
The fix is three lines, keyed on a variable Vercel sets for every deployment:
const KEY_PREFIX = VERCEL_ENV && VERCEL_ENV !== 'production' ? `${VERCEL_ENV}:` : '';Proving it works needs no access to the database. Like an article on a preview URL, then read the production count and confirm it did not move. That is a black-box check against the two endpoints, and it tests the thing that actually matters rather than the key that implements it.
The NUL byte that made 221 lines binary
A code review pass before merge found something no type checker was ever going to.
src/lib/likes.ts | Bin 0 -> 8189 bytesThe agent had written a literal U+0000 into the template that builds the hash input, and then read the file back twice without seeing it. It renders as a space. Git’s binary heuristic keys on exactly that byte, so the site’s only server-side module would have landed in the pull request as “Binary file not shown”, permanently invisible to git diff, git blame, and grep.
That last one is the quiet part. grep -rn "LikeState" src/ returned nothing while the file sat there containing it. The fix is behaviour-identical and one character wide:
return createHash('sha256').update(`${address}\0${salt}`).digest('base64url').slice(0, 32);After that, git diff --stat reported 221 +++++ instead of Bin.
Three bugs before the smoke test could pass
Writing the endpoint took an afternoon. Proving it worked took four pull requests.
The gap is that pnpm check and pnpm build never call the route. They prove it compiles and bundles, which leaves two things unproven until something hits the deployed function: getCollection('blog') running inside a Vercel function, and the Upstash client actually reaching Redis. I merged the feature without opening the preview deployment, so the first real execution of both was on the live site. It worked, but that was luck rather than evidence.
So the next change was a workflow that smoke-tests the endpoint on every deployment. It failed three times.
The first bug was a matcher. It looked for published articles with ^status: *published against frontmatter that actually reads status: 'published', quotes included. It matched nothing, fell through its loop, and reported this:
No published article found to test against.That message describes missing content. The problem was a broken pattern, and a wrong message costs more than a wrong result.
The second was the trigger. deployment_status fires for every GitHub Deployment on a repository, and this one has two kinds: Vercel’s, and the one created by the environment: production block in the existing CI job. For the second kind, target_url is an Actions job page. The workflow dutifully curled github.com.
| Created by | target_url |
|---|---|
vercel[bot] |
the deployment’s site URL |
the environment: production block |
https://github.com/…/actions/runs/…/job/… |
It now gates on the creator rather than on the URL’s shape, so adding a custom domain later will not quietly disable it.
The third was authentication. vercel deploy prints the deployment-specific URL rather than the production alias, and deployment-specific URLs are covered by Deployment Protection. The request came back as a redirect to a login page, and the step tried to parse its body as a count:
Unexpected body from the like endpoint: Redirecting...The fix is a Protection Bypass for Automation secret, sent as an x-vercel-protection-bypass header. Testing the deployment URL rather than the alias turns out to be the better check anyway, because it hits the exact artefact just built without waiting for the alias to move.
The common thread is worth naming. GitHub only dispatches deployment_status for workflow files on the default branch, so this workflow could not run until it had already merged. Every one of those three bugs had to ship to be found. What stopped a fourth round was running each check by hand, with the bypass secret, before merging the fix.
Once it passed, five lines of the run log carried what the previous sections argued:
GET /api/likes/a2a-mcp-... -> {"count":0}unknown slug -> 404POST like -> {"count":1,"liked":true}POST unlike -> {"count":0,"liked":false}production before={"count":0} after={"count":0}The last line is the namespacing proof, running unattended on every future pull request.
The figure check blamed a server that was answering
A fourth wrong message turned up while this article was being written, in this blog’s own tooling rather than in anything the like counter touched. pnpm figures:fit renders every figure and measures its labels against their boxes. It began failing the full sweep while a single-article run passed:
Could not reach http://localhost:4321. Start the dev server first: pnpm devThe dev server was up and answering curl on that port throughout. The diagnosis was in the timing, not the message: the first page settled in 639 ms and the second timed out at exactly 30,000 ms with no requests in flight at all. The check navigated with Playwright’s waitUntil: 'networkidle', and Vite holds an HMR WebSocket open, so the zero-connection state it waits for never reliably arrives. The catch around the navigation then reported every failure as a dead server.
Switching to waitUntil: 'load' fixed it, and the sweep now measures 60 pages in about three seconds. The part worth keeping is what nearly went wrong next. networkidle had been doing a second, unstated job: waiting for the self-hosted mono webfont that 28 of the figure labels are measured in. Dropping it without adding an explicit document.fonts.ready would not have broken the run. It would have quietly changed the measurements, which is the more expensive failure.
Summary
- A like stored only in the reader’s browser is not a signal. It gives the reader nothing they did not already have and the author nothing at all, which is the argument for paying the cost of a real counter.
- An increment-only endpoint is not a count. The vote record is what makes the number evidence, and it is also what makes unlike possible, which is what lets the button carry
aria-pressedhonestly. - One serverless route costs more than the route. The adapter moved the build output, and
pagefind --site <missing>writes an empty index and exits0. - Function region and database region are one decision. Upstash’s primary region cannot be changed after creation, and Vercel’s default sits on another continent.
- A
U+0000in a source file makes git treat it as binary, which hides it fromdiff,blame, andgrepwhile the file looks completely normal in an editor. - Work that can only be tested by shipping it will ship broken.
deployment_statusdispatches from the default branch only, so each fix had to merge before it could be checked. - A check that names the wrong cause costs more than one that fails cleanly. Twice here a message described missing content or a dead server when the real fault was a pattern that matched nothing and a navigation that timed out.
References
- Astro’s on-demand rendering guide, including opting single routes out of a static build with
export const prerender = false - The
@astrojs/verceladapter reference - Upstash Redis pricing, with the free tier’s monthly command allowance
- Vercel’s region list, which maps
hnd1to the AWSap-northeast-1datacentre in Tokyo - Vercel’s guide to giving agents and CI access to protected deployments with a bypass secret
- Vercel’s note that sensitive environment variables cannot be decrypted once created
- MDN on
aria-pressed, including the rule that a toggle button’s label must not change with its state