
Astro article badges from Vercel Web Analytics — resolved in the build, shipped as static HTML
How this Astro blog badges its most-read articles: one Vercel Web Analytics query during the build, summed across three locales, baked into static HTML.
On this page
Introduction
The home page of this blog sorts by publishedAt and by nothing else. Every article therefore looks exactly as important as every other one, so a piece that found readers last year sits below whatever I published on Tuesday, and a visitor has no way to tell which is which. I wanted the articles people actually read to say so.
There was no measurement of any kind on the site to base that on, and no obvious place to put any. This is a static Astro build with exactly one server route. A static site can rank its own articles perfectly well, provided it does the ranking before it becomes a static site. One Vercel Web Analytics query runs during astro build, the result is baked into the HTML, and a scheduled weekly rebuild keeps it from going stale.
This article covers the feasibility check, the privacy decision that cost more than the code did, the build-time mechanism itself, and a defect in the tracking script’s placement that every automated check on this repo passed straight over.
What the home page could not say
The relevant part of the data layer is four lines. Articles come out of the content collection, get filtered for visibility, and sort:
function byNewestFirst(a: BlogEntry, b: BlogEntry): number { return b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf() || a.id.localeCompare(b.id);}There is no other ranking input, and that is the correct default. I did not want to replace it. Reverse chronological is what a blog index is for. What I wanted was a second signal layered on top: a small badge on the few articles worth pointing at, leaving the order alone.
The site did already have one reader signal. A like button shipped a few releases earlier, keyed on the article slug and backed by Upstash Redis. It is a good feature and it is not a popularity signal, because almost nobody clicks a like button. A tally that sparse cannot separate a well-read article from a quiet one.
Asking whether a static site can count views at all
The naive version of the question was whether this needed a database. It did not, and the reason is recent: Vercel made its Web Analytics API public in May 2026. Since the site already deploys to Vercel from GitHub Actions with a token in scope, the numbers can be fetched during the build rather than at request time.
Claude checked that before anything was written, which was the right order. The first call failed:
{"error":{"code":"bad_request","message":"Invalid request: missing required property `since`."}}That is a useful failure. A 400 for a missing parameter means the endpoint is reachable and the plan is not the thing refusing, which is the thing that could have killed the idea. Vercel documents the reporting window per plan but never states whether the API itself is available on Hobby. With since and until supplied:
curl --get "https://api.vercel.com/v1/query/web-analytics/visits/aggregate" \ -H "Authorization: Bearer $VERCEL_TOKEN" \ --data-urlencode "projectId=$VERCEL_PROJECT_ID" \ --data-urlencode "since=2026-07-19" --data-urlencode "until=2026-08-17" \ --data-urlencode "by=requestPath" --data-urlencode "limit=100"{"version":1,"query":{"groupBy":["requestPath"],"limit":100},"data":[]}A 200 with an empty array answered the feasibility question. Hobby has API access, the reporting window is 30 days, and data is empty only because no tracking script had been deployed yet.
The enablement dialog states the same limits up front, which is worth reading rather than discovering later:

Two of those four lines shaped the design. The 30-day history means the badge can only ever describe recent traffic, which turned out to be the more useful question anyway. Capped ingestion means collection pauses rather than bills when the allowance runs out.
The objection, and why I overrode it
Claude laid out four candidate signals and recommended the one I did not pick: derive the badge from the existing like counts. That option collects nothing new, needs no script, and leaves the privacy page untouched. It was the conservative recommendation, and the reasoning behind it was sound.
The objection attached to it was specific. The privacy page on this site was published, dated, and written in three languages, and it opened by saying the site has “no analytics, no advertising, and no tracking scripts” and that “exactly one feature” sends anything to a server. src/data/privacy.ts makes that binding in its own header comment:
/** * What this page says has to stay true. It describes exactly one server-side * behaviour, in `src/lib/likes.ts`, and if that file's storage or retention * changes then these paragraphs are wrong until they are changed too. */I chose Vercel Web Analytics anyway, for two reasons. Likes are too sparse to rank on, as above. The metadata depth is the part I actually wanted: referrer, approximate geography, browser and device. A tally of clicks tells me a number. Analytics tells me where readers came from, which is the question behind “which articles are worth writing more of”.
That made rewriting the privacy page part of the feature rather than a follow-up. Three published claims had to be retracted and replaced with an accurate description, in English, Japanese and Traditional Chinese. The sequencing rule was the important half: the privacy copy ships in the same commit as the script, never after. A deploy carrying the tracker while the page still said there was no tracking is the one outcome that is worse than not building the feature.
The rewritten page names what Vercel’s own documentation says is collected, including the parts that are broader than the like button:
| Collected | Retained |
|---|---|
| Timestamp, URL, referrer | Aggregated |
| Geolocation to city level | Aggregated |
| Browser, OS, device type | Aggregated |
| A hash derived from the request | Discarded after 24 hours |
No cookies, and nothing written to the reader’s browser. That part of the old page survived intact and stayed.
Where the badge gets decided
Resolving a view count at request time needs a server on every page load, which would mean putting this static site behind a rendered route to display a badge. Resolving it at build time needs nothing at runtime at all. The count is fetched once while Astro is building, the badge becomes part of the emitted HTML, and the reader downloads a static file exactly as before.
This repo already had the pattern. astro.config.mjs calls buildLastmodMap() at config load and injects the result into the sitemap, for the same reason: external data resolved during the build, baked into the output.
The cost of build-time resolution is staleness. HTML written on Monday still says what was true on Monday. That is handled by a scheduled trigger on the existing workflow rather than a new one:
schedule: # Mondays 03:00 UTC — midday Monday in JST. - cron: '0 3 * * 1'A scheduled run checks out the default branch, so github.ref is refs/heads/main and the existing deploy job’s gates pass unchanged. That matters more than it looks: it keeps exactly one path to production. A second workflow that also deployed would be two competing production deploys per merge, which is precisely what the comment above that job warns about.
Three URLs, one article
Every article here exists at up to three URLs, one per locale. The analytics API returns those as three separate requestPath rows, and ranking the rows directly would split one article’s readership three ways. A monolingual article would then outrank a translated one that is read more in total.
So the rows are folded onto the slug before anything is ranked. The normalisation has two traps in it and both fail silently:
export function slugFromRequestPath(requestPath: string): string | null { if (requestPath.includes('[') || requestPath.includes(']')) return null;
let path = requestPath.split('?')[0] ?? ''; if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
for (const locale of LOCALES) { const prefix = localePrefix(locale); if (prefix && path.startsWith(`${prefix}/`)) { path = path.slice(prefix.length); break; } }
return ARTICLE_PATH.exec(path)?.[1] ?? null;}The trailing slash is the first. Astro emits <loc> with one and browsers request both forms, so a comparison that skips normalising never matches, and reports that nothing is popular, forever. The locale prefix is the second, and it is derived from localePrefix rather than written out as /ja and /zh-tw so that a fourth language cannot be forgotten here.
The like endpoint had reached the same conclusion from the opposite direction, months earlier, and says so in its own header: a like on the Japanese translation is a like on the article. Two features arriving independently at “the slug is the identity, the URL is not” is a reasonable sign it is the right answer.
Absence and contradiction are different failures
There is no token during local development, in a preview deployment, in a fork, or in the CI check job. None of those should fail a build over a badge, so they warn and ship nothing.
The problem is that a parsing bug produces an identical result. If slugFromRequestPath stopped matching, the badge set would be empty and the build would be green, and the site would report that nothing is popular for as long as nobody looked closely. So the two cases are separated deliberately:
| Condition | Meaning | Behaviour |
|---|---|---|
| No token or project ID | Not configured | Warn, no badges |
| Fetch fails or non-2xx | Vercel unreachable | Warn, no badges |
| Zero article rows | Genuinely no traffic yet | Warn, no badges |
| Article rows exist, none match a known slug | Parsing is broken | Throw |
That last row fails the build on purpose. This repo already had the pattern in src/i18n/article-locale.ts, which derives the current locale two independent ways and throws when they disagree, because the failure it guards is silent and would otherwise ship an English diagram on a Japanese page.
The tracker truncated every page’s head
Vercel’s Astro snippet puts the component in <head>, so that is where it went:
<head> <!-- ... --> <Analytics /> <slot name="head" /></head>pnpm check reported zero errors, zero warnings and zero hints. pnpm build succeeded. CI was green. A code review pass over the diff is what caught it, and a browser measurement is what confirmed it.
The component renders a custom element, <vercel-analytics>, rather than a <script> tag. Per the HTML parsing specification an unknown element encountered in the “in head” insertion mode pops the head, switches to “after head”, and reprocesses. The head ends at that element, and every node authored after it is placed in the body instead.
Reading the built file gives no hint of this. The markup is well formed, </head> sits at character 6276 with every stylesheet before it, and only a parser disagrees:
// .vercel/output/static/index.html, loaded in Chromiumdocument.head.querySelectorAll('style, link[rel=stylesheet]').length // 0document.body.querySelectorAll('style, link[rel=stylesheet]').length // 2document.querySelector('vercel-analytics').parentElement.tagName // "BODY"Astro injects its stylesheets at the end of the head, which is after the element, so they were landing in the body. Nothing looked broken, because a stylesheet still applies from the body. What it actually cost was <slot name="head" />, authored one line below: the search page’s Pagefind stylesheet was displaced too, and any <meta> or canonical link routed through that slot later would have been silently inert.
The fix is to render it in the body. After moving it, the same measurement reads 2 in the head and 0 in the body, and the search page carries both of its stylesheets where they belong.
What version 2 turned out not to give
@vercel/analytics v2 was chosen over v1 for one feature. Resilient intake replaces the fixed /_vercel/insights/script.js path with a randomised per-project one, so that a single global blocklist rule cannot match every Vercel site at once. The argument for it here was not volume but bias: readers of a technical blog block trackers unevenly, and the more technical the article the more of its audience is missing. Since the badge ranks on visitor counts, a skewed input badges the wrong articles rather than merely fewer of them.
It is not active on this site. The live page loads the predictable path:
https://oharu-tech-blog.vercel.app/_vercel/insights/script.jsThe first guess was that vercel deploy --prebuilt was dropping the build-time configuration, since this repo builds in GitHub Actions rather than on Vercel. That guess was wrong. A preview deployment, which Vercel builds itself, emits byte-identical output. Running vercel pull confirms the variable is simply never issued to this project; 28 environment variables come down and VERCEL_OBSERVABILITY_CLIENT_CONFIG is not among them.
Setting it by hand is the wrong repair. The value has to contain the unique path Vercel provisions on its own edge, that path is not discoverable, and the configuration replaces the defaults rather than supplementing them. A guessed path would return 404 and collection would stop entirely, with a dashboard showing zeroes and nothing to explain them. The component already reads the variable, so the feature switches itself on if Vercel ever issues it. v2 is behaving exactly as v1 here, and the correct action is to leave it alone.
Verification
pnpm check runs five tools on this repo and reports hints as a separate category from warnings, so the totals are worth quoting rather than summarising:
Result (179 files):- 0 errors- 0 warnings- 0 hintsThe parsing and folding logic is exercised by 22 assertions covering the trailing slash, both locale prefixes, query strings, route patterns, the trilingual sum, the deterministic tiebreak, and both directions of the throw. The sum test is the one that matters most: 10 + 5 + 2 across three locale URLs has to come out as 17 on one slug.
Two build paths were checked. With no credentials the build succeeds, renders zero badges and prints one warning naming the missing variables. With credentials it succeeds and distinguishes an empty window from a misconfigured one. A probe counting outbound requests confirmed one API call across all 158 pages built, which is what the module-scope memo exists for.
One pre-existing warning is unrelated to any of this and worth stating rather than leaving in the output: @astrojs/vercel reports that the local Node 26 is unsupported and the runtime will be Node 24. CI pins 24, so it is local only.
Summary
The technical question answered itself in a single curl. A static site can rank its own articles, because the ranking happens while it is still a build, and everything after that is a normal data-fetching problem with an unusual deadline.
What actually cost something was everything around it:
- A published privacy page is a specification. Adding analytics to a site that had promised it had none meant retracting three claims in three languages, in the same commit as the script.
- Absence and contradiction look identical from outside. A missing token and a broken parser both produce an empty badge set, so they were made to behave differently on purpose.
- A green build is not a parsed build. The head truncation was invisible to five checking tools and to the file on disk, and visible immediately to a browser.
- The feature a version upgrade was chosen for may not be switched on. Confirming that took one
vercel pull, and the right response was to change nothing.
The badge itself is currently unearned, and correctly so. The floor that gates it is set above the traffic that exists, so nothing carries it until enough people have read something for the label to mean anything.
References
- Query Web Analytics with the API, including the aggregate endpoint and its required
sinceanduntilparameters - Vercel Web Analytics privacy and compliance, listing every field stored per data point
- Pricing for Web Analytics, including the Hobby plan’s 30-day reporting window
- Advanced Web Analytics config, covering what version 2 adds and the client configuration variable
- HTML Standard: the “in head” insertion mode, whose anything-else branch pops the head element