Astroで構築した静的サイト(SSG)に「人気記事ランキング」を設置したい。でも SSG はビルド時に HTML を生成する仕組みなので、WordPress のプラグインのようにアクセス数をリアルタイムで取得するわけにはいきません。
そこで活躍するのが Google Analytics 4(GA4)Data API です。ビルド時に GA4 から直近 30 日間のページビューを取得し、閲覧数の多い記事をランキングとして静的 HTML に書き出す ― というアプローチで、サーバーレスかつ高速な人気記事サイドバーを実現できます。
この記事では、実際に GleamHub のメディアサイト「GH Media」で人気記事ランキングを実装した経験をもとに、セットアップからデプロイまでの全手順と、本番環境で遭遇しやすいエラーと対処法を包み隠さずまとめます。
全体のアーキテクチャ
まず、仕組みの全体像を把握しておきましょう。
┌─────────────────────────────────────────────────┐
│ Cloud Build (CI/CD) │
│ │
│ 1. npm ci │
│ 2. astro build │
│ ├─ GA4 Data API に問い合わせ(30日分) │
│ ├─ 人気スラッグ Top 5 を取得 │
│ └─ 各ページの HTML にランキングを埋め込み │
│ 3. gsutil rsync → GCS へデプロイ │
│ │
│ Secret Manager ──→ GA4_CREDENTIALS (環境変数) │
└─────────────────────────────────────────────────┘
ポイントは「ビルド時に 1 回だけ API を叩く」という点です。SSG サイトは全ページが静的 HTML なので、デプロイ後はサーバー負荷ゼロ・API コストゼロで人気記事を表示できます。
前提条件
- Astro プロジェクトが構築済み
- GA4 プロパティが設定済みで、対象サイトのアクセスデータが蓄積されている
- Google Cloud プロジェクトが用意されている(Cloud Build を使う場合)
手順 1:GA4 Data API の有効化とサービスアカウント作成
1-1. API の有効化
Google Cloud Console で「Google Analytics Data API」を有効にします。
gcloud services enable analyticsdata.googleapis.com \
--project=YOUR_PROJECT_ID
1-2. サービスアカウントの作成
gcloud iam service-accounts create ga4-api-access \
--display-name="GA4 API Access" \
--project=YOUR_PROJECT_ID
1-3. サービスアカウントキーの発行
gcloud iam service-accounts keys create ga4-key.json \
--iam-account=ga4-api-access@YOUR_PROJECT_ID.iam.gserviceaccount.com
1-4. GA4 プロパティへの閲覧権限付与
GA4 の管理画面 → 「プロパティのアクセス管理」から、作成したサービスアカウントのメールアドレスを 閲覧者ロールで追加します。
注意: API を有効化しただけでは GA4 のデータにアクセスできません。GA4 側でサービスアカウントに権限を付与する手順を忘れがちなので注意してください。
手順 2:Astro プロジェクトへの実装
2-1. パッケージのインストール
npm install @google-analytics/data
2-2. 環境変数の設定
プロジェクトルートに .env ファイルを作成します。
GA4_PROPERTY_ID="YOUR_GA4_PROPERTY_ID"
GA4_CREDENTIALS='ここに ga4-key.json の中身を 1 行の JSON として貼り付け'
GA4_CREDENTIALS には、発行した JSON キーの内容をそのままシングルクォートで囲んで設定します。改行は \n のまま保持してください。
GA4 プロパティ ID は、GA4 管理画面の「プロパティ設定」→「プロパティ ID」で確認できます。
2-3. TypeScript 型定義(任意)
src/env.d.ts に型情報を追加しておくと便利です。
interface ImportMetaEnv {
readonly GA4_PROPERTY_ID: string;
readonly GA4_CREDENTIALS: string;
}
2-4. GA4 データ取得ユーティリティ
src/utils/popularPosts.ts を作成します。
import { BetaAnalyticsDataClient } from "@google-analytics/data";
const NON_ARTICLE_SLUG_PATTERN = /^(tag-|page$)/;
interface PageViewData {
slug: string;
views: number;
}
function isArticleSlug(slug: string): boolean {
if (!slug || slug.includes("/")) return false;
if (NON_ARTICLE_SLUG_PATTERN.test(slug)) return false;
return true;
}
// ビルド中に何度も呼ばれても API は 1 回だけ
let cachedResult: PageViewData[] | null = null;
export async function getPopularSlugs(
limit: number = 5
): Promise<PageViewData[]> {
if (cachedResult !== null) return cachedResult.slice(0, limit);
const propertyId =
import.meta.env.GA4_PROPERTY_ID ?? process.env.GA4_PROPERTY_ID;
const credentialsJson =
import.meta.env.GA4_CREDENTIALS ?? process.env.GA4_CREDENTIALS;
if (!propertyId || !credentialsJson) {
console.warn("GA4 環境変数が未設定のため、人気記事を取得できません");
return [];
}
try {
const credentials = JSON.parse(credentialsJson);
const client = new BetaAnalyticsDataClient({
credentials: {
client_email: credentials.client_email,
// Secret Manager 経由だと \n がリテラル文字列のまま
// 残ることがあるため、明示的に改行文字へ置換
private_key: credentials.private_key.replace(/\\n/g, "\n"),
},
});
const fetchLimit = limit * 4; // フィルタ前に多めに取得
const [response] = await client.runReport({
property: `properties/${propertyId}`,
dateRanges: [{ startDate: "30daysAgo", endDate: "today" }],
dimensions: [{ name: "pagePath" }],
metrics: [{ name: "screenPageViews" }],
dimensionFilter: {
filter: {
fieldName: "pagePath",
stringFilter: {
matchType: "BEGINS_WITH",
value: "/media/",
},
},
},
orderBys: [
{ metric: { metricName: "screenPageViews" }, desc: true },
],
limit: fetchLimit,
});
if (!response?.rows) {
cachedResult = [];
return [];
}
const results = response.rows
.map((row) => {
const pagePath = row.dimensionValues?.[0]?.value ?? "";
const slug = pagePath
.replace(/^\/media\//, "")
.replace(/\/$/, "");
const views = Number(row.metricValues?.[0]?.value ?? 0);
return { slug, views };
})
.filter((item) => isArticleSlug(item.slug));
cachedResult = results;
return results.slice(0, limit);
} catch (e) {
console.error("GA4 人気記事の取得に失敗しました:", e);
return [];
}
}
重要なポイントを 3 つ解説します。
ポイント 1:import.meta.env と process.env の両方をチェック
const propertyId =
import.meta.env.GA4_PROPERTY_ID ?? process.env.GA4_PROPERTY_ID;
Astro(Vite)の import.meta.env は .env ファイルから読み込んだ値のみを返します。一方、Cloud Build の secretEnv や CI 環境の環境変数は process.env に格納されます。ローカルと本番の両方で動作させるには、両方からの取得を試みる必要があります。
ポイント 2:秘密鍵の \n を明示的に改行へ置換
private_key: credentials.private_key.replace(/\\n/g, "\n"),
JSON 内の PEM 秘密鍵には \n が含まれていますが、環境変数の受け渡し方によってはリテラル文字列(バックスラッシュ+n)のまま残ることがあります。これを改行文字に変換しないと OpenSSL がキーを解読できずエラーになります(後述)。
ポイント 3:結果をキャッシュする
let cachedResult: PageViewData[] | null = null;
SSG ビルドでは、人気記事コンポーネントを含むすべてのページでこの関数が呼ばれます。キャッシュがないと、記事が 40 本あれば GA4 API を 40 回叩くことになり、ビルド時間が爆増するうえ API のレートリミットに引っかかる可能性もあります。
2-5. 人気記事コンポーネント
src/components/media/PopularPosts.astro を作成します。
---
import { getCollection } from 'astro:content';
import { getPopularSlugs } from '../../utils/popularPosts';
const allMedia = await getCollection('media', ({ data }) => !data.draft);
// GA4 から人気記事を取得(失敗時は最新順にフォールバック)
const popularSlugs = await getPopularSlugs(5);
let popularPosts;
if (popularSlugs.length > 0) {
const slugOrder = new Map(
popularSlugs.map((s, i) => [s.slug, i])
);
popularPosts = allMedia
.filter((post) => slugOrder.has(post.slug))
.sort(
(a, b) =>
(slugOrder.get(a.slug) ?? 0) - (slugOrder.get(b.slug) ?? 0)
);
}
// GA4 が取得できなかった場合は最新記事にフォールバック
if (!popularPosts || popularPosts.length === 0) {
popularPosts = [...allMedia]
.sort(
(a, b) =>
new Date(b.data.date).getTime() -
new Date(a.data.date).getTime()
)
.slice(0, 5);
}
---
<ol class="popular-article-list">
{popularPosts.map((post, index) => (
<li class="popular-article-item">
<a href={`/media/${post.slug}/`}>
<span class:list={[
"rank-badge",
{ "rank-top": index < 3 }
]}>
{index + 1}
</span>
<div class="popular-article-info">
<time>
{new Date(post.data.date).toLocaleDateString('ja-JP')}
</time>
<h4>{post.data.title}</h4>
</div>
</a>
</li>
))}
</ol>
GA4 の取得に失敗した場合でも最新記事でフォールバックすることで、ビルドが止まらずサイドバーが空にもならない設計です。
手順 3:Cloud Build + Secret Manager でのデプロイ
ローカルでは .env ファイルで動きますが、本番の CI/CD 環境では Secret Manager を使って認証情報を安全に渡します。
3-1. Secret Manager にシークレットを登録
# プロパティ ID
echo -n "YOUR_GA4_PROPERTY_ID" | \
gcloud secrets create GA4_PROPERTY_ID \
--data-file=- \
--project=YOUR_PROJECT_ID
# サービスアカウント JSON
cat ga4-key.json | \
gcloud secrets create GA4_CREDENTIALS \
--data-file=- \
--project=YOUR_PROJECT_ID
ここが最大の落とし穴です。 JSON ファイルを Secret Manager に登録する際、ファイルの末尾が切れていないか必ず確認してください。
-----END PRIVATE KEY-----が欠落していると、ビルド時に OpenSSL エラーが発生します(後述のトラブルシューティング参照)。
3-2. Cloud Build サービスアカウントに権限を付与
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
--member="serviceAccount:[email protected]" \
--role="roles/secretmanager.secretAccessor"
3-3. cloudbuild.yaml の設定
steps:
- name: "node:20"
entrypoint: "npm"
args: ["ci"]
- name: "node:20"
entrypoint: "npx"
args: ["astro", "sync"]
- name: "node:20"
entrypoint: "npm"
args: ["run", "build"]
secretEnv: ["GA4_PROPERTY_ID", "GA4_CREDENTIALS"]
- name: "gcr.io/cloud-builders/gsutil"
args: ["-m", "rsync", "-d", "-r", "dist", "gs://your-bucket"]
availableSecrets:
secretManager:
- versionName: projects/YOUR_PROJECT_ID/secrets/GA4_PROPERTY_ID/versions/latest
env: GA4_PROPERTY_ID
- versionName: projects/YOUR_PROJECT_ID/secrets/GA4_CREDENTIALS/versions/latest
env: GA4_CREDENTIALS
Tips: Node.js のバージョンは
node:20のように明示的に指定することをおすすめします。node:latestだと予期しないバージョンアップで挙動が変わるリスクがあります。
トラブルシューティング ― 本番で遭遇しやすいエラー集
ここからが本記事の核心です。ローカルでは動くのに本番で動かない ― そんなとき、エラーメッセージから原因を特定できるよう、実際に遭遇したケースをまとめました。
エラー 1:DECODER routines::unsupported
GA4 人気記事の取得に失敗しました: Error: 2 UNKNOWN:
Getting metadata from plugin failed with error:
error:1E08010C:DECODER routines::unsupported
原因は 2 つ考えられます。
原因 A:秘密鍵の \n が改行になっていない
Secret Manager やCI環境変数を経由すると、JSON 内の \n(エスケープシーケンス)がリテラル文字列のまま渡されることがあります。PEM 形式の秘密鍵は正しい改行がないと OpenSSL がデコードできません。
対処法:
private_key: credentials.private_key.replace(/\\n/g, "\n"),
原因 B:秘密鍵が途中で切れている
Secret Manager に JSON を登録する際、コピー&ペーストで -----END PRIVATE KEY----- が欠落するケースがあります。秘密鍵の末尾は必ず以下の形式で終わっている必要があります。
...base64エンコードされたキーデータ...
-----END PRIVATE KEY-----
確認方法:
gcloud secrets versions access latest \
--secret=GA4_CREDENTIALS \
--project=YOUR_PROJECT_ID | \
node -e "
const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
const k = d.private_key;
console.log('END marker exists:', k.includes('-----END PRIVATE KEY-----'));
console.log('Key length:', k.length);
"
END marker exists: false と表示されたら、シークレットを正しい JSON で再登録してください。
エラー 2:GA4 環境変数が未設定のため、人気記事を取得できません
原因: CI 環境で環境変数が渡されていない。
import.meta.env は Vite が .env ファイルから読み込んだ値のみを含みます。Cloud Build の secretEnv で渡された値は process.env にしか入りません。
対処法:
// NG: ローカルでしか動かない
const propertyId = import.meta.env.GA4_PROPERTY_ID;
// OK: ローカルでも CI でも動く
const propertyId =
import.meta.env.GA4_PROPERTY_ID ?? process.env.GA4_PROPERTY_ID;
エラー 3:ビルドは成功するが人気記事が最新順になっている
原因: GA4 API の呼び出しが失敗し、フォールバック(最新記事順)が適用されている。
Cloud Build のログを確認しましょう。
gcloud builds log BUILD_ID --project=YOUR_PROJECT_ID 2>&1 | \
grep "GA4"
エラーが出ていれば、上記のエラー 1・2 のいずれかに該当するはずです。エラーが出ていなければ、GA4 プロパティへのサービスアカウントの閲覧権限を確認してください。
エラー 4:ビルド時間が異常に長い・API レートリミット
原因: キャッシュなしで全ページのビルドごとに GA4 API を呼んでいる。
SSG では人気記事コンポーネントを含むページ数だけ getPopularSlugs() が実行されます。記事 40 本 × タグページ 60 件 = 100 回以上の API コール、ということになりかねません。
対処法: モジュールレベルでキャッシュ変数を持ち、2 回目以降はキャッシュを返す設計にします。
let cachedResult: PageViewData[] | null = null;
export async function getPopularSlugs(limit: number = 5) {
if (cachedResult !== null) return cachedResult.slice(0, limit);
// ... API 呼び出し ...
cachedResult = results;
return results.slice(0, limit);
}
エラー 5:Permission denied / 403
原因: 以下のいずれかです。
- GA4 Data API が有効化されていない
- サービスアカウントに GA4 プロパティの閲覧権限がない
- Cloud Build サービスアカウントに Secret Manager の
secretAccessorロールがない
チェックリスト:
| チェック項目 | 確認コマンド |
|---|---|
| API 有効化 | gcloud services list --enabled で analyticsdata.googleapis.com を確認 |
| GA4 権限 | GA4 管理画面 → プロパティのアクセス管理 |
| Secret Manager 権限 | gcloud projects get-iam-policy PROJECT_ID で secretAccessor を確認 |
まとめ
Astro SSG で GA4 Data API を使った人気記事ランキングを実装するポイントを整理します。
| 項目 | ポイント |
|---|---|
| 環境変数 | import.meta.env と process.env の両方をチェック |
| 秘密鍵 | \n の改行置換を明示的に行う。Secret Manager 登録時は末尾の END マーカーまで含まれているか確認 |
| パフォーマンス | 結果をキャッシュし、ビルド中の API 呼び出しは 1 回に抑える |
| フォールバック | API 失敗時は最新記事順で表示し、ビルドを止めない |
| Node.js バージョン | Cloud Build では node:20 のように明示指定する |
SSG の「ビルド時にデータを取得して静的に書き出す」という特性を正しく理解し、環境差異と秘密鍵のフォーマットに注意すれば、サーバーレスでコストゼロの人気記事ランキングが手に入ります。
GleamHub では Astro をはじめとするモダンな Web 技術を活用したコーポレートサイト制作やメディア構築を承っています。「ウチのサイトにも入れたい」という方は、お気軽にお問い合わせください。