# 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.

- Source: https://oharu121.com/blog/astro-like-counter-upstash-redis-vercel-serverless-route/
- Published: 2026-08-16T16:27:12+09:00
- Tags: Astro, Vercel, Redis, Testing

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

```ts title="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.

*Figure — VoteRecord: 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:

```ts title="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.

```ts title="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.

*Figure — RequestPath: 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.

```json title="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.

*Figure: 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.

*Figure: 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.

*Figure: 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.

```json title="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.

*Figure — KeyNamespaces: 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:

```ts title="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.

```text
 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:

```ts title="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:

```text
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:

```text
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:

```text
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:

```text
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

- [Astro's on-demand rendering guide, including opting single routes out of a static build with `export const prerender = false`](https://docs.astro.build/en/guides/on-demand-rendering/)
- [The `@astrojs/vercel` adapter reference](https://docs.astro.build/en/guides/integrations-guide/vercel/)
- [Upstash Redis pricing, with the free tier's monthly command allowance](https://upstash.com/pricing/redis)
- [Vercel's region list, which maps `hnd1` to the AWS `ap-northeast-1` datacentre in Tokyo](https://vercel.com/docs/regions)
- [Vercel's guide to giving agents and CI access to protected deployments with a bypass secret](https://vercel.com/docs/deployment-protection/automated-agent-access)
- [Vercel's note that sensitive environment variables cannot be decrypted once created](https://vercel.com/docs/environment-variables/sensitive-environment-variables)
- [MDN on `aria-pressed`, including the rule that a toggle button's label must not change with its state](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-pressed)
