# Vercel Web Analytics で人気記事にバッジを付ける — ビルド時に解決し、静的サイトのまま配信する

> このブログが人気記事にバッジを付ける仕組み。ビルド中に Vercel Web Analytics へ 1 回問い合わせ、3 つのロケールを合算して静的 HTML に焼き込みます。

- Source: https://oharu121.com/ja/blog/astro-build-time-vercel-analytics-popular-article-badges/
- Published: 2026-08-17T18:00:50+09:00
- Tags: Astro, Vercel

---
## はじめに

このブログのトップページは `publishedAt` だけで並んでいます。そのためどの記事も同じ重みに見え、去年読まれた記事が火曜日に公開したものの下に沈み、訪問者にはどれがどれだか分かりません。実際に読まれている記事には、そう分かるようにしたいと考えました。

しかしその判断材料になる計測はサイトに一切なく、置き場所も見当たりませんでした。これはサーバールートが 1 つだけの静的な Astro ビルドです。**静的サイトでも自分の記事を十分に順位付けできます。静的サイトになる前に順位付けを済ませておけばよいのです。** `astro build` の実行中に Vercel Web Analytics へ 1 回問い合わせ、その結果を HTML に焼き込み、週次の定期リビルドで古びないようにしています。

本記事では、実現可能性の確認、コードよりも高くついたプライバシーの判断、ビルド時の仕組みそのもの、そしてこのリポジトリの自動チェックがすべて素通りしたトラッキングスクリプトの配置の欠陥を扱います。

## トップページが伝えられなかったこと

データ層の該当箇所は 4 行です。記事はコンテンツコレクションから取り出され、表示可否でフィルタされ、並べ替えられます。

```ts title="src/lib/blog.ts"
function byNewestFirst(a: BlogEntry, b: BlogEntry): number {
	return b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf() || a.id.localeCompare(b.id);
}
```

順位付けの入力はこれ以外になく、既定としてはそれが正解です。**これを置き換えるつもりはありませんでした。** ブログの一覧が逆時系列であるのは当然のことです。欲しかったのはその上に重ねる 2 つ目のシグナルで、並び順はそのままに、取り上げる価値のある数本へ小さなバッジを付けることでした。

読者側のシグナルはすでに 1 つありました。数リリース前に導入した「いいね」ボタンで、記事のスラッグをキーとし、裏側は Upstash Redis です。良い機能ではありますが、人気を測るシグナルではありません。「いいね」ボタンを押す人はほとんどいないからです。**それほど疎らな集計では、よく読まれている記事と静かな記事を区別できません。**

## そもそも静的サイトで閲覧数を数えられるのか

素朴な問いは、これにデータベースが必要かどうかでした。必要ありません。理由は最近のもので、Vercel が 2026 年 5 月に Web Analytics API を公開したからです。このサイトはすでに GitHub Actions からトークン付きで Vercel へデプロイしているので、数値はリクエスト時ではなくビルド中に取得できます。

Claude は何も書き始める前にそれを確認しました。順序として正しい判断です。最初の呼び出しは失敗しました。

```json
{"error":{"code":"bad_request","message":"Invalid request: missing required property `since`."}}
```

これは有用な失敗です。**パラメータ不足による `400` は、エンドポイントに到達できており、拒否しているのはプランではないことを意味します。**プランによる拒否こそ、この案を潰しかねないものでした。Vercel はレポート期間をプランごとに文書化していますが、API 自体が Hobby で使えるかどうかは書いていません。`since` と `until` を渡すと、次のようになります。

```bash
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"
```

```json
{"version":1,"query":{"groupBy":["requestPath"],"limit":100},"data":[]}
```

**空配列を伴う `200` が、実現可能性への答えでした。**Hobby でも API は使え、レポート期間は 30 日、そして `data` が空なのはトラッキングスクリプトをまだデプロイしていないからにすぎません。

有効化のダイアログは同じ制限を最初に提示します。後から気づくより、ここで読んでおく価値があります。

*Figure: レポート期間を決めているのは Hobby の 30 日という履歴であり、カスタムイベントがないことでページビュー以外の計測は選択肢から外れます。*

この 4 行のうち 2 行が設計を左右しました。**30 日の履歴ということは、バッジが語れるのは直近のトラフィックだけです。**結果的にはそちらのほうが有用な問いでした。取り込みに上限があるということは、割り当てを使い切った時点で課金ではなく収集が停止するということです。

## 反対意見と、それを押し切った理由

Claude は 4 つの候補シグナルを並べ、そのうち私が選ばなかったものを推奨しました。既存の「いいね」数からバッジを導く案です。この案は新たに何も収集せず、スクリプトも要らず、プライバシーページにも手を触れません。**保守的な推奨であり、その根拠も筋が通っていました。**

添えられていた反対意見は具体的なものでした。このサイトのプライバシーページは公開済みで日付が入っており、3 言語で書かれていて、冒頭で「アクセス解析も広告もトラッキングスクリプトも使っていない」こと、そしてサーバーに何かを送信する機能は「ひとつだけ」であることを述べていました。`src/data/privacy.ts` はそれを自身のヘッダーコメントで拘束しています。

```ts title="src/data/privacy.ts"
/**
 * 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.
 */
```

それでも私は Vercel Web Analytics を選びました。理由は 2 つあります。ひとつは前述のとおり、「いいね」は順位付けに使うには疎らすぎることです。**もうひとつ、メタデータの厚みこそが本当に欲しかった部分でした。**リファラー、おおよその地域、ブラウザとデバイスです。クリック数の集計が教えてくれるのは数字だけです。アクセス解析は読者がどこから来たのかを教えてくれます。それが「どの記事をもっと書く価値があるのか」という問いの背後にあるものです。

**その結果、プライバシーページの書き直しは後追いの作業ではなく機能の一部になりました。**公開済みの主張 3 つを撤回し、正確な記述に置き換える必要があり、それを英語・日本語・繁体字中国語で行いました。重要だったのは順序の決まりのほうです。**プライバシーの文面はスクリプトと同じコミットで出す。後からでは駄目だ**ということです。トラッキングを載せたデプロイが出ているのにページには「トラッキングはない」と書いてある状態は、この機能を作らないことよりも悪い唯一の結末です。

書き直したページには、Vercel 自身のドキュメントが収集すると述べている内容を、「いいね」より広い部分も含めて明記しています。

| 収集されるもの | 保持のされ方 |
| --- | --- |
| 日時、URL、リファラー | 集計値として |
| 都市レベルまでの位置情報 | 集計値として |
| ブラウザ、OS、デバイス種別 | 集計値として |
| リクエストから導出したハッシュ | 24 時間で破棄 |

Cookie はなく、読者のブラウザには何も書き込みません。旧ページのその部分はそのまま残りました。

## バッジがどこで決まるのか

*Figure — WhereDecided: どちらのレーンにも同じ読者と同じ API が登場します。違うのは問い合わせが走るタイミングで、読者のリクエストのたびか、デプロイごとに 1 回、読者が来るはるか前かです。*

閲覧数をリクエスト時に解決するには、ページの読み込みごとにサーバーが必要になります。バッジを表示するためだけに、この静的サイトをレンダリングされるルートの背後へ置くということです。**ビルド時に解決するなら、実行時には何も要りません。**カウントは Astro のビルド中に一度だけ取得され、バッジは出力される HTML の一部になり、読者はこれまでどおり静的ファイルをダウンロードします。

このリポジトリにはすでに同じ型がありました。`astro.config.mjs` は設定の読み込み時に `buildLastmodMap()` を呼び、その結果をサイトマップへ注入しています。理由も同じで、外部データをビルド中に解決し、出力へ焼き込むというものです。

ビルド時解決の代償は鮮度です。月曜に書き出された HTML は、月曜時点の内容のままです。これは新しいワークフローではなく、既存のワークフローに定期実行のトリガーを足して扱っています。

```yaml title=".github/workflows/ci.yml"
  schedule:
    # Mondays 03:00 UTC — midday Monday in JST.
    - cron: '0 3 * * 1'
```

定期実行は既定ブランチをチェックアウトするので `github.ref` は `refs/heads/main` になり、既存のデプロイジョブのゲートはそのまま通ります。これは見た目以上に重要で、**本番へ至る経路が 1 本だけに保たれます。**デプロイするワークフローをもう 1 つ足せば、マージのたびに本番デプロイが 2 つ競合することになり、それはそのジョブの上にあるコメントが警告しているとおりのことです。

## 3 つの URL、1 つの記事

*Figure — OneTally: 行のまま順位付けすると、この記事は 3 位・7 位・11 位に散ります。スラッグに畳み込めば 1 位です。訪問者数は説明用の値です。*

ここの記事はいずれもロケールごとに最大 3 つの URL を持ちます。アナリティクス API はそれらを 3 つの別々な `requestPath` 行として返すため、**行のまま順位付けすると 1 つの記事の読者が 3 つに割れてしまいます。**そうなると、単一言語の記事のほうが、合計ではより読まれている翻訳付きの記事を上回ってしまいます。

そこで、順位付けの前に行をスラッグへ畳み込みます。**この正規化には罠が 2 つあり、どちらも静かに失敗します。**

```ts title="src/lib/popularity.ts"
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;
}
```

1 つ目は末尾のスラッシュです。Astro は `<loc>` をスラッシュ付きで出力し、ブラウザは両方の形でリクエストするため、**正規化を省いた比較は一度も一致せず、人気記事は存在しないと永久に報告し続けます。**2 つ目はロケール接頭辞です。`/ja` や `/zh-tw` と直接書くのではなく `localePrefix` から導出しているので、4 つ目の言語が増えてもここで忘れられることはありません。

「いいね」のエンドポイントは数か月前に、反対方向から同じ結論に達しており、自身のヘッダーにこう書いています。日本語訳への「いいね」は、その記事への「いいね」である、と。**2 つの機能が独立に「同一性はスラッグにあり、URL にはない」へ行き着いたことは、それが正しい答えである妥当な証拠です。**

## 不在と矛盾は別の失敗である

ローカル開発中、プレビューデプロイ、フォーク、CI のチェックジョブでは、トークンがありません。そのいずれもバッジのためにビルドを失敗させるべきではないので、警告を出して何も出力しません。

**問題は、パースのバグが同じ結果を生むことです。**`slugFromRequestPath` が一致しなくなれば、バッジの集合は空になり、ビルドは緑のままで、誰かがよく見るまでサイトは「人気記事はない」と報告し続けます。そこで 2 つの場合を意図的に分けています。

| 状況 | 意味 | 挙動 |
| --- | --- | --- |
| トークンまたはプロジェクト ID がない | 未設定 | 警告、バッジなし |
| 取得の失敗、あるいは 2xx 以外 | Vercel に到達できない | 警告、バッジなし |
| 記事の行が 0 件 | 本当にまだトラフィックがない | 警告、バッジなし |
| 記事の行はあるが既知のスラッグに一致しない | パースが壊れている | **例外を投げる** |

**最後の行は意図的にビルドを失敗させます。**このリポジトリには `src/i18n/article-locale.ts` に同じ型がすでにあります。現在のロケールを 2 通り独立に導出し、食い違ったら例外を投げるというもので、そこで守っている失敗が静かであり、放置すれば日本語のページに英語の図版を出してしまうからです。

## トラッカーがすべてのページの head を打ち切っていた

*Figure — HeadTruncation: ファイルの中では何も動いていません。動いたのは境界のほうで、その下に書かれていたものはすべて body 側へ連れて行かれました。*

Vercel の Astro 向けスニペットはコンポーネントを `<head>` に置いているので、そのとおりに置きました。

```astro title="src/layouts/BaseLayout.astro"
<head>
  <!-- ... -->
  <Analytics />
  <slot name="head" />
</head>
```

`pnpm check` はエラー 0、警告 0、ヒント 0 を報告しました。`pnpm build` も成功しました。**CI は緑でした。**気づいたのは差分に対するコードレビューで、裏付けたのはブラウザでの計測です。

このコンポーネントがレンダリングするのは `<script>` タグではなく、`<vercel-analytics>` というカスタム要素です。HTML のパース仕様では、"in head" 挿入モードで未知の要素に出会うと、head を閉じて "after head" へ移り、その要素を処理し直します。**head はその要素の位置で終わり、以降に書かれたノードはすべて body に置かれます。**

**ビルド済みのファイルを読んでもこれは分かりません。**マークアップは整形式で、`</head>` は 6276 文字目にあり、スタイルシートはすべてその前にあります。異を唱えるのはパーサーだけです。

```js
// Chromium で .vercel/output/static/index.html を読み込んだ場合
document.head.querySelectorAll('style, link[rel=stylesheet]').length  // 0
document.body.querySelectorAll('style, link[rel=stylesheet]').length  // 2
document.querySelector('vercel-analytics').parentElement.tagName      // "BODY"
```

Astro はスタイルシートを head の末尾に注入します。つまりこの要素より後なので、body に落ちていました。壊れて見えなかったのは、body からでもスタイルシートは適用されるからです。実際に失われていたのは 1 行下に書かれていた `<slot name="head" />` のほうで、検索ページの Pagefind のスタイルシートも一緒に移動しており、後からそのスロット経由で `<meta>` や canonical のリンクを通したとしても、静かに無効になっていたはずです。

修正は body でレンダリングすることです。移した後、同じ計測は head で 2、body で 0 を返し、検索ページも 2 つのスタイルシートを本来あるべき場所に持つようになりました。

## バージョン 2 が結局くれなかったもの

`@vercel/analytics` の v2 を v1 より優先したのは、機能ひとつのためでした。resilient intake は固定の `/_vercel/insights/script.js` というパスをプロジェクトごとにランダム化されたものへ置き換えるので、単一のグローバルなブロックリスト規則ですべての Vercel サイトを一度に捕まえることができなくなります。ここでの論拠は量ではなく偏りでした。技術ブログの読者はトラッカーを一様にはブロックせず、技術的な記事ほど読者の欠落が大きくなります。バッジは訪問者数で順位を付けるので、入力が偏れば、単に少なく出るのではなく別の記事にバッジが付いてしまいます。

このサイトでは有効になっていません。公開中のページは予測可能なパスを読み込みます。

```
https://oharu-tech-blog.vercel.app/_vercel/insights/script.js
```

最初は、このリポジトリが Vercel 上ではなく GitHub Actions でビルドしているため、`vercel deploy --prebuilt` がビルド時の設定を落としているのだろうと考えました。**その推測は間違いでした。**Vercel 自身がビルドするプレビューデプロイも、バイト単位で同じ出力を吐きます。`vercel pull` を実行すると、この変数がそもそもこのプロジェクトに配られていないことが分かります。環境変数は 28 個下りてきて、その中に `VERCEL_OBSERVABILITY_CLIENT_CONFIG` はありません。

**手で設定するのは誤った処置です。**この値には Vercel が自身のエッジに用意する固有パスが入っている必要があり、そのパスは知る手段がなく、しかもこの設定は既定値を補うのではなく置き換えます。当てずっぽうのパスは 404 を返し、収集は完全に止まり、ダッシュボードにはゼロが並ぶだけで説明は何も残りません。コンポーネント側はすでにこの変数を読んでいるので、Vercel が配るようになれば機能はひとりでに有効になります。**ここでの v2 は v1 とまったく同じ挙動であり、正しい行動は手を触れないことです。**

## 検証

`pnpm check` はこのリポジトリで 5 つのツールを走らせ、ヒントを警告とは別のカテゴリとして報告します。要約ではなく数値をそのまま引用する価値があります。

```
Result (179 files):
- 0 errors
- 0 warnings
- 0 hints
```

パースと畳み込みのロジックは 22 件のアサーションで検証しています。末尾スラッシュ、2 つのロケール接頭辞、クエリ文字列、ルートパターン、3 言語の合算、決定的なタイブレーク、そして例外を投げる場合と投げない場合の両方です。**最も重要なのは合算のテストです。**3 つのロケール URL にまたがる `10 + 5 + 2` が、1 つのスラッグ上で `17` にならなければなりません。

ビルドの経路は 2 つ確認しました。認証情報がない場合、ビルドは成功し、バッジは 0 個で、欠けている変数名を挙げた警告を 1 つ出します。認証情報がある場合は成功し、空の期間と設定漏れを区別します。送信リクエストを数えるプローブで、**ビルドした 158 ページ全体で API 呼び出しが 1 回**であることを確認しました。モジュールスコープのメモ化はそのために存在します。

これとは無関係な既存の警告が 1 つあり、出力に残したままにするより書いておく価値があります。`@astrojs/vercel` が、ローカルの Node 26 はサポート対象外でランタイムは Node 24 になる、と報告するものです。CI は 24 に固定しているので、ローカルだけの話です。

## まとめ

技術的な問いは `curl` 1 回で自ら答えを出しました。静的サイトでも自分の記事を順位付けできます。順位付けはまだビルドである間に行われるからで、その後はすべて、締め切りが少し変わっただけの普通のデータ取得の問題です。

実際に高くついたのは、その周りにあったものすべてでした。

- **公開されたプライバシーページは仕様書です。**アクセス解析はないと約束していたサイトに解析を足すということは、3 つの主張を 3 言語で撤回し、それをスクリプトと同じコミットで出すということでした。
- **不在と矛盾は外からは同じに見えます。**トークンの欠落も壊れたパーサーも、どちらも空のバッジ集合を生むので、意図的に別々の挙動をとらせました。
- **緑のビルドはパース済みのビルドではありません。**head の打ち切りは 5 つのチェックツールにもディスク上のファイルにも見えず、ブラウザには即座に見えました。
- **バージョンを上げた目的の機能が、有効になっているとは限りません。**それを確かめるのに要したのは `vercel pull` 1 回で、正しい対応は何も変えないことでした。

バッジ自体はいまのところ誰も獲得していません。それで正しいのです。バッジを通す下限は現状のトラフィックより上に置いてあるので、その表示に意味が出るだけ読まれるまでは、どの記事にも付きません。

## 参考リンク

- [Web Analytics を API で問い合わせる。aggregate エンドポイントと、必須パラメータである `since` と `until` を含む](https://vercel.com/docs/analytics/web-analytics-api)
- [Vercel Web Analytics のプライバシーとコンプライアンス。データポイントごとに保存される項目の一覧を含む](https://vercel.com/docs/analytics/privacy-policy)
- [Web Analytics の料金。Hobby プランの 30 日のレポート期間を含む](https://vercel.com/docs/analytics/limits-and-pricing)
- [Web Analytics の詳細設定。バージョン 2 で追加されたものとクライアント設定変数について](https://vercel.com/docs/analytics/package)
- [HTML Standard: "in head" 挿入モード。その anything-else 分岐が head 要素を閉じる](https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inhead)
