Next.jsのサイドバーにGA4の実データで閲覧数ランキングを作る【認証情報をブラウザに出さない構成】
- この記事で分かること
- GA4のページビュー数をGoogle Analytics Data APIで取得し、サーバーコンポーネントで集計してから、タブ切り替えだけをクライアントコンポーネントに任せる実装
- 実際に使った環境
- Next.js 16.3.1(App Router)/ @google-analytics/data / Vercel。このブログのサイドバーで実際に動いている実装です
- 筆者の結論
- ランキング表示そのものより、unstable_cacheが失敗結果までデプロイをまたいで保持する点と、表示前に記事を絞り込むと件数が足りなくなる点のほうが、実際にハマったポイントでした
このブログのサイドバーには、「閲覧数ランキング」というセクションがあります。累計と直近30日をタブで切り替えられる、よく読まれている記事のTop5です。
この並び順は、手で並べたものではなく、Google Analytics(GA4)に実際に記録されたページビュー数にもとづいています(閲覧数そのものは画面に表示せず、順位だけを出しています)。Next.jsのサーバー側からGoogle Analytics Data APIに問い合わせて取得しています。この記事では、その実装と、実際に運用して踏んだ2つの不具合を紹介します。
🛠 実装してみて分かったこと
APIからデータを取ってきて並べるだけなら、半日もかかりませんでした。時間を取られたのは、その周りです。公開直後にAPIの有効化を忘れていたせいで空の結果がキャッシュされ、APIを有効にして再デプロイしてもランキングが出てこない。直ったと思ったら、今度は5件表示のはずが3件しか出ない。どちらも、キャッシュと絞り込みの順番という、ランキングの本筋とは関係ない部分で起きた問題でした。
全体の構成
構成は次の3つに分かれています。
| ファイル | 種類 | 役割 |
|---|---|---|
src/lib/analytics.ts | サーバー専用モジュール | GA4への問い合わせとキャッシュ |
src/components/Sidebar.tsx | サーバーコンポーネント(async) | 累計・30日の両方を取得し、記事タイトルを付けて絞り込む |
src/components/ViewRankingToggle.tsx | クライアントコンポーネント | 「累計/直近30日」のタブ切り替えだけを担当 |
ポイントは、GA4への問い合わせはすべてサーバー側で終わらせ、クライアントコンポーネントには表示用の配列だけを渡すことです。タブを切り替えるたびにブラウザからAPIを呼ぶ設計にすると、認証情報をブラウザ側に持たせる必要が出てきます。最初から両方の期間のデータをサーバーで取得して渡しておけば、クライアント側は受け取った配列を切り替えて表示するだけで済みます。
このように「ページ全体はサーバーコンポーネント、状態を持つ小さな部分だけクライアントコンポーネント」に分ける考え方は、サーバーコンポーネントとクライアントコンポーネントの使い分けの記事でも書いた、このブログの基本方針そのままです。
事前準備: 計測タグとは別にData APIの設定が必要
GA4の計測タグの導入手順はSearch Console・Analyticsの導入記事で書きましたが、計測タグはあくまで「データを送る」ための設定です。サイトから「データを読む」には、別の準備が必要でした。
- Google Cloud Consoleで「Google Analytics Data API」を有効化する
- サービスアカウントを作成し、JSON形式の鍵ファイルをダウンロードする
- GA4の管理画面の「プロパティのアクセス管理」で、サービスアカウントのメールアドレスを「閲覧者」として追加する
- GA4の「プロパティ設定」に表示されているプロパティID(数字のみ)を控える
- Vercelの環境変数に、
GA4_PROPERTY_ID・GA4_CLIENT_EMAIL・GA4_PRIVATE_KEYの3つを登録する
環境変数にはNEXT_PUBLIC_を付けていません。Next.jsでは、NEXT_PUBLIC_で始まる環境変数はブラウザに配信されるJavaScriptに埋め込まれます。サービスアカウントの秘密鍵は、絶対にブラウザへ出してはいけない値です。
GA4に問い合わせる部分
src/lib/analytics.tsでは、まず環境変数を読み込みます。
const propertyId = process.env.GA4_PROPERTY_ID || "";
const clientEmail = process.env.GA4_CLIENT_EMAIL || "";
const privateKey = (process.env.GA4_PRIVATE_KEY || "").replace(/\\n/g, "\n");
/** 3つの環境変数がすべて揃っているかどうか。サイドバー側でこの値を見てセクション自体の表示を出し分ける。 */
export const isViewRankingConfigured = Boolean(propertyId && clientEmail && privateKey);秘密鍵は、ダウンロードしたJSONに入っている\nを含む1行の文字列のままVercelに登録しています。環境変数として読み込むと\nが「バックスラッシュとn」の2文字のまま届くため、replaceで本物の改行に戻しています。これを忘れると、鍵の形式が不正として認証に失敗します。
isViewRankingConfiguredは、3つの環境変数がすべて揃っているかを表すフラグです。揃っていない間(ローカル開発環境など)は、サイドバーにランキングのセクション自体を出しません。環境変数が無いせいでビルドが失敗する、ということも起きません。
問い合わせの本体は次のとおりです。
async function fetchTopViewedSlugs(days: "all" | number, limit: number): Promise<ViewRankingItem[]> {
const client = getClient();
if (!client) return [];
try {
const [response] = await client.runReport({
property: `properties/${propertyId}`,
dateRanges: [
{
startDate: days === "all" ? "2020-01-01" : `${days}daysAgo`,
endDate: "today",
},
],
dimensions: [{ name: "pagePath" }],
metrics: [{ name: "screenPageViews" }],
dimensionFilter: {
filter: {
fieldName: "pagePath",
stringFilter: { matchType: "BEGINS_WITH", value: "/blog/" },
},
},
orderBys: [{ metric: { metricName: "screenPageViews" }, desc: true }],
limit: limit * 4,
});
const seenSlugs = new Set<string>();
const results: ViewRankingItem[] = [];
for (const row of response.rows ?? []) {
const path = row.dimensionValues?.[0]?.value ?? "";
const views = Number(row.metricValues?.[0]?.value ?? 0);
const match = path.match(/^\/blog\/([^/?]+)/);
if (!match) continue;
const slug = match[1];
if (seenSlugs.has(slug)) continue;
seenSlugs.add(slug);
results.push({ slug, views });
if (results.length >= limit) break;
}
return results;
} catch (error) {
console.error("[analytics] GA4からの閲覧数ランキング取得に失敗しました", error);
return [];
}
}- 期間の指定: 累計は
2020-01-01から今日まで、直近30日は30daysAgoから今日まで。GA4のAPIはNdaysAgoという相対指定を受け付けます /blog/配下だけに絞る: トップページやカテゴリーページは記事ではないため、dimensionFilterでパスが/blog/から始まる行だけを取得します- 同じ記事の重複をまとめる: GA4のページパスには、クエリ文字列付きのURLなど、同じ記事が別の行として記録されることがあります。パスからslugを取り出し、すでに出てきたslugは読み飛ばします。重複を除いた後でも必要な件数が残るよう、APIからは
limitの4倍を取得しています - 失敗したら空配列を返す: API側の障害でサイト全体の表示が壊れないよう、例外はここで受け止めます。呼び出し側は「空ならセクションを出さない」と判断します
サイドバー側: 両方の期間を並行して取得する
Sidebar.tsxはasyncのサーバーコンポーネントです。累計と直近30日の2つの問い合わせをPromise.allで並行して実行し、それぞれに記事タイトルを付けてから、表示件数に絞ります。
const RANKING_DISPLAY_COUNT = 5;
const RANKING_CANDIDATE_COUNT = 20;
let allTimeRanking: ViewRankingEntry[] = [];
let last30DaysRanking: ViewRankingEntry[] = [];
if (isViewRankingConfigured) {
const summaryBySlug = new Map(
getIndexablePostSummaries().map((p) => [p.slug, { title: p.title }])
);
const [allTimeRaw, last30Raw] = await Promise.all([
getTopViewedSlugs("all", RANKING_CANDIDATE_COUNT),
getTopViewedSlugs(30, RANKING_CANDIDATE_COUNT),
]);
allTimeRanking = resolveTitles(allTimeRaw, summaryBySlug).slice(0, RANKING_DISPLAY_COUNT);
last30DaysRanking = resolveTitles(last30Raw, summaryBySlug).slice(0, RANKING_DISPLAY_COUNT);
}resolveTitlesは、GA4から返ってきたslugに記事タイトルを付ける関数です。検索エンジンにインデックスさせている公開記事の一覧(getIndexablePostSummaries())に無いslugは、ここで自然に除外されます。削除した記事や、noindexにしている記事がランキングに出てこないのはこのためです。
クライアント側: タブ切り替えだけ
ViewRankingToggle.tsxは、受け取った2つの配列のどちらを表示するかをuseStateで持つだけのクライアントコンポーネントです。
export default function ViewRankingToggle({ allTime, last30Days }: Props) {
const [range, setRange] = useState<"all" | "30d">("all");
const items = range === "all" ? allTime : last30Days;
// タブのボタンと、itemsのリストを描画する
}1〜3位の順位の数字は、金・銀・銅の色で少し大きく表示しています。
キャッシュ: 全ページで結果を共有する
このブログの各ページはrevalidate = 3600(1時間ごとに再生成)の設定です。サイドバーはほぼ全ページに表示されるため、何も工夫しないと、各ページの再生成のたびにGA4への問い合わせが発生し、同じ内容をページ数の分だけ取得することになります。
そこで、問い合わせ関数をNext.jsのunstable_cacheで包み、結果を全ページで共有するようにしました。
export const getTopViewedSlugs = unstable_cache(
(days: "all" | number, limit: number) => fetchTopViewedSlugs(days, limit),
["ga4-top-viewed-slugs-v2"],
{ revalidate: 3600 }
);キャッシュのキーの末尾に付いている-v2には理由があります。次の不具合で付けたものです。
不具合1: APIを有効にしても、ランキングが出てこない
最初のデプロイの時点で、Google Cloud Console側で「Google Analytics Data API」を有効化し忘れていました。当然、問い合わせは権限エラーで失敗し、fetchTopViewedSlugsは例外を受け止めて空配列を返します。
問題はここからです。unstable_cacheは、この空配列という失敗結果もそのままキャッシュしていました。しかもunstable_cacheのキャッシュはデプロイをまたいで保持されます。APIを有効にして再デプロイしても、キャッシュが切れるまでは「空」という古い結果が返り続け、ランキングは表示されませんでした。
対処として、キャッシュのキーをga4-top-viewed-slugsからga4-top-viewed-slugs-v2に変更しました。キーが変われば別のキャッシュとして扱われるため、次のアクセスで改めてGA4に問い合わせが走ります。
注意
エラー時に空の値を返す関数をunstable_cacheで包むと、一時的な失敗も正常な結果と同じようにキャッシュされます。外部APIの設定を後から直した場合は、キャッシュの有効期限が切れるのを待つか、キーを変えて作り直す必要があります。
不具合2: Top5のはずが、5件に満たない
もう1つは、ランキングの件数が5件より少なく表示される不具合でした。
最初の実装では、GA4から上位5件だけを取得し、その後で公開記事の一覧と照らし合わせて絞り込んでいました。ところがGA4には、noindexにしている記事のページビューも記録されています。上位5件の中に1件でもnoindexの記事があると、絞り込んだ後には4件以下しか残りません。
原因は、「取得する件数」と「最終的に表示する件数」を同じにしていたことでした。そこで、この2つを分けました。
- GA4からは、表示候補として20件を取得する(
RANKING_CANDIDATE_COUNT) - 公開記事だけに絞り込んでから、上位5件を表示する(
RANKING_DISPLAY_COUNT)
コード上のlimitという引数名だけを見ると「表示件数」と読めてしまうので、analytics.tsのコメントには「これは候補件数であって、画面に出す件数とは意図的に別にしている」ことを書き残しています。
よくある質問
Q. GA4の計測タグを入れていれば、ランキングも作れますか? A. 計測タグだけでは作れません。計測タグはデータを送る側の設定で、サイトからGA4のデータを読み取るには、別途Google Analytics Data APIの有効化と、サービスアカウントの作成・GA4プロパティへの権限付与が必要です。
Q. 環境変数にNEXT_PUBLIC_を付けないのはなぜですか?
A. NEXT_PUBLIC_を付けた環境変数は、ブラウザに配信されるJavaScriptに埋め込まれます。サービスアカウントの秘密鍵が漏れると、GA4のデータを第三者に読まれてしまうため、サーバー側でしか読めない名前にしています。
Q. ページを表示するたびにGA4へ問い合わせているのですか?
A. いいえ。Next.jsのunstable_cacheで結果を1時間キャッシュし、サイドバーを表示する全ページで共有しています。実際にGA4へ問い合わせるのは、キャッシュが切れたときだけです。
まとめ
GA4の実データでランキングを作る仕組み自体は、Data APIに問い合わせてslugごとに集計するだけの素直なものです。設計で意識したのは、問い合わせをすべてサーバー側で終わらせて、認証情報をブラウザに一切渡さないことでした。
実際に手こずったのは、失敗結果までキャッシュされる点と、絞り込みの前に件数を削ってしまう点です。どちらも、外部APIの結果を加工して表示する機能なら、ランキングに限らず起こりうる問題だと思います。