Tags
The Upstash interlocking spiral logo in green beside the Upstash wordmark in white on a black card

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:

src/lib/likes.ts
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.

A like writes a tally and a vote record, and the vote record is what makes the tally evidenceTwo keys are shown side by side. The first, likes colon slug, is the tally: a plain integer with no expiry. The second, likes colon by colon slug colon hash, is the vote record: a salted SHA-256 of the caller address that expires after thirty days. Beneath them, the second key is credited with three outcomes: a repeat request moves nothing, unlike is possible, and aria-pressed is honest. Without the second key, one loop makes the number meaningless.What one like writesThe tallylikes:<slug>A plain integer. No TTL.The vote recordlikes:by:<slug>:<hash>Salted SHA-256 of the address. Expires after 30 days.What the second key buysA repeat request moves nothingUnlike is possiblearia-pressed is honestWithout itOne loop and the number means nothing
The vote record is what makes the tally evidence rather than a number. It is also what makes unlike possible.

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:

src/lib/likes.ts
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.

src/pages/api/likes/[slug].ts
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.

A cached read stops at the CDN; only a like reaches the function and RedisA left-to-right chain of four stages: reader, Vercel CDN, function, and Upstash Redis. The function and the database are both labelled as sitting in Tokyo, hnd1 and ap-northeast-1. A cached read, marked s-maxage equals 60, stops at the CDN and never reaches the function. A like, marked no-store, travels the whole chain to Redis and back.How far a request actually travelsReaderVercel CDNFunctionhnd1, TokyoUpstash Redisap-northeast-1Cached readStops heres-maxage=60A likeGoes all the wayno-store
A reader who never clicks never reaches the function. Only a like, or a cache miss, goes the whole way.

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.

package.json
"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 Vercel Marketplace product grid for Upstash, with four cards: Upstash for Redis outlined in red, Upstash Vector, Upstash QStash slash Workflow, and Upstash Search. Each card carries an Install button and its pricing tiers.

Four products share the integration. The Redis one is the only one this needed.

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

The Install Integration panel in Vercel. Under Environments, Production and Preview are checked and Development is unchecked. A Custom Prefix field shows the placeholder STORAGE with a _URL suffix. A Sensitive toggle is switched on.

Environments, prefix, and the Sensitive toggle. The third one is the one that cannot be undone.

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.

The provisioned Upstash database in Vercel. Status reads Available, Plan reads Free. A Quickstart panel on the .env.local tab lists five variables: KV_REST_API_READ_ONLY_TOKEN, KV_REST_API_TOKEN, KV_REST_API_URL, KV_URL, and REDIS_URL, all with their values masked.

Five variables land in the project. The code uses two of them.

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.

vercel.json
{ "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.

One Redis database holds two keyspaces, so a preview like cannot move the published tallyA single Upstash Redis database is shown connected to two environments. The production column, where VERCEL_ENV equals production, uses the unprefixed key likes colon slug and needs no later migration. The preview column, where VERCEL_ENV equals preview, uses preview colon likes colon slug, and every branch write lands there. Below, the check that proves the split: like an article on a preview, then read the production count and confirm the number has not moved.One database, connected to both environmentsUpstash RedisProductionVERCEL_ENV=productionlikes:<slug>No prefix. Nothing to migrate later.PreviewVERCEL_ENV=previewpreview:likes:<slug>Every branch write lands here.The check that proves itLike on a preview, then read production. The number must not move.
One database, two keyspaces. Production keys keep the plain name so nothing has to be migrated if this is ever removed.

The fix is three lines, keyed on a variable Vercel sets for every deployment:

src/lib/likes.ts
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 bytes

The 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:

src/lib/likes.ts
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 -> 404
POST 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 dev

The 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-pressed honestly.
  • One serverless route costs more than the route. The adapter moved the build output, and pagefind --site <missing> writes an empty index and exits 0.
  • 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+0000 in a source file makes git treat it as binary, which hides it from diff, blame, and grep while the file looks completely normal in an editor.
  • Work that can only be tested by shipping it will ship broken. deployment_status dispatches 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

Share this article