You want to set up a "popular post ranking" on a static site (SSG) built with Astro. However, because SSG generates HTML at build time, you cannot fetch page views in real time like a WordPress plugin.
That is where the Google Analytics 4 (GA4) Data API comes in. By fetching page views from the past 30 days from GA4 at build time and exporting the most-viewed articles as a ranking into static HTML, you can achieve a serverless, blazing-fast popular posts sidebar.
Based on our hands-on experience implementing the popular post ranking on GleamHub's media site, GH Media, this article shares the complete process from setup to deployment alongside common production errors and their solutions without holding anything back.
Overall architecture
First, let's understand the high-level picture of how it works.
┌─────────────────────────────────────────────────┐
│ 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 (環境変数) │
└─────────────────────────────────────────────────┘
The key point is to call the API only once at build time. Since all pages on an SSG site are static HTML, once deployed, popular articles can be displayed with zero server load and zero API cost.
Prerequisites
- An Astro project is already set up
- A GA4 property is configured and has accumulated traffic data for the target site
- A Google Cloud project is prepared (if using Cloud Build)
Step 1: Enabling the GA4 Data API and creating a service account
1-1. Enabling the API
Enable "Google Analytics Data API" in Google Cloud Console.
gcloud services enable analyticsdata.googleapis.com \
--project=YOUR_PROJECT_ID
1-2. Creating a service account
gcloud iam service-accounts create ga4-api-access \
--display-name="GA4 API Access" \
--project=YOUR_PROJECT_ID
1-3. Issuing a service account key
gcloud iam service-accounts keys create ga4-key.json \
--iam-account=ga4-api-access@YOUR_PROJECT_ID.iam.gserviceaccount.com
1-4. Granting viewer permissions to the GA4 property
In GA4 Admin → "Property Access Management," add the email address of the created service account with the Viewer role.
Note: Simply enabling the API does not grant access to GA4 data. People often forget the step of granting permissions to the service account in GA4, so please take care.
Step 2: Implementation in the Astro project
2-1. Installing packages
npm install @google-analytics/data
2-2. Configuring environment variables
Create a .env file in the project root.
GA4_PROPERTY_ID="YOUR_GA4_PROPERTY_ID"
GA4_CREDENTIALS='ここに ga4-key.json の中身を 1 行の JSON として貼り付け'
In GA4_CREDENTIALS, set the contents of the issued JSON key as-is, enclosed in single quotes. Keep newlines as \n.
The GA4 Property ID can be found in GA4 Admin under "Property Settings" → "Property ID."
2-3. TypeScript type definitions (optional)
It is useful to add type definitions to src/env.d.ts.
interface ImportMetaEnv {
readonly GA4_PROPERTY_ID: string;
readonly GA4_CREDENTIALS: string;
}
2-4. GA4 data retrieval utility
Create 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 [];
}
}
Here are three key points explained.
Point 1: Check both import.meta.env and process.env
const propertyId =
import.meta.env.GA4_PROPERTY_ID ?? process.env.GA4_PROPERTY_ID;
Astro's (Vite's) import.meta.env returns only values loaded from the .env file. On the other hand, Cloud Build's secretEnv and CI environment variables are stored in process.env. To make it work in both local and production environments, you need to attempt retrieval from both.
Point 2: Explicitly replace \n in the private key with newlines
private_key: credentials.private_key.replace(/\\n/g, "\n"),
The PEM private key inside the JSON contains \n, but depending on how environment variables are passed, it may remain as a literal string (backslash + n). If you do not convert this into newline characters, OpenSSL will not be able to decode the key and will throw an error (discussed later).
Point 3: Cache the results
let cachedResult: PageViewData[] | null = null;
In an SSG build, this function is called on every page containing the popular posts component. Without caching, 40 articles would trigger 40 calls to the GA4 API, causing build times to surge dramatically and potentially hitting API rate limits.
2-5. Popular posts component
Create 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>
It is designed to fall back to the latest posts even if GA4 retrieval fails, ensuring that the build does not stop and the sidebar is never left blank.
Step 3: Deployment with Cloud Build + Secret Manager
While it works with a .env file locally, in the production CI/CD environment credentials are passed securely using Secret Manager.
3-1. Registering secrets in 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
This is the biggest pitfall. When registering the JSON file in Secret Manager, make sure to verify that the end of the file is not truncated. If
-----END PRIVATE KEY-----is missing, an OpenSSL error will occur at build time (see Troubleshooting below).
3-2. Granting permissions to the Cloud Build service account
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
--member="serviceAccount:YOUR_BUILD_SA@cloudbuild.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor"
3-3. Configuring 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
Tip: We recommend explicitly specifying the Node.js version, such as
node:20. Usingnode:latestcarries the risk that unexpected version upgrades may alter behavior.
Troubleshooting — common errors encountered in production
This is the core of this article. It works locally but fails in production—to help you pinpoint the cause from error messages when that happens, we have compiled actual cases we encountered.
Error 1: DECODER routines::unsupported
GA4 人気記事の取得に失敗しました: Error: 2 UNKNOWN:
Getting metadata from plugin failed with error:
error:1E08010C:DECODER routines::unsupported
There are two possible causes.
Cause A: \n in the private key has not been converted to newlines
When passed via Secret Manager or CI environment variables, the \n escape sequence inside the JSON can sometimes be passed as a literal string. OpenSSL cannot decode a PEM-formatted private key without proper newlines.
Solution:
private_key: credentials.private_key.replace(/\\n/g, "\n"),
Cause B: The private key is truncated
When registering JSON in Secret Manager, -----END PRIVATE KEY----- can sometimes be dropped during copy-pasting. The end of the private key must always terminate in the following format:
...base64エンコードされたキーデータ...
-----END PRIVATE KEY-----
How to check:
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);
"
If END marker exists: false is displayed, re-register the secret with the correct JSON.
Error 2: GA4 環境変数が未設定のため、人気記事を取得できません
Cause: The environment variable is not passed in the CI environment.
import.meta.env contains only values loaded by Vite from the .env file. Values passed via secretEnv in Cloud Build are only accessible in process.env.
Solution:
// NG: ローカルでしか動かない
const propertyId = import.meta.env.GA4_PROPERTY_ID;
// OK: ローカルでも CI でも動く
const propertyId =
import.meta.env.GA4_PROPERTY_ID ?? process.env.GA4_PROPERTY_ID;
Error 3: Build succeeds, but popular posts are ordered by latest
Cause: The GA4 API call failed, and the fallback (ordered by latest articles) was applied.
Check the Cloud Build logs.
gcloud builds log BUILD_ID --project=YOUR_PROJECT_ID 2>&1 | \
grep "GA4"
If an error is logged, it should correspond to Error 1 or 2 above. If no error appears, verify the service account's viewer permissions on the GA4 property.
Error 4: Abnormally long build times / API rate limits
Cause: Calling the GA4 API for every page build without caching.
In SSG, getPopularSlugs() is executed as many times as there are pages containing the popular posts component. 40 articles × 60 tag pages could easily result in over 100 API calls.
Solution: Maintain a cache variable at the module level and design it to return the cache from the second call onward.
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);
}
Error 5: Permission denied / 403
Cause: One of the following:
- The GA4 Data API is not enabled
- The service account lacks viewer permissions for the GA4 property
- The Cloud Build service account lacks the Secret Manager
secretAccessorrole
Checklist:
| Check item | Verification command |
|---|---|
| API enablement | Check analyticsdata.googleapis.com with gcloud services list --enabled |
| GA4 permissions | GA4 Admin → Property Access Management |
| Secret Manager permissions | Check secretAccessor with gcloud projects get-iam-policy PROJECT_ID |
Conclusion
Here is a summary of the key points for implementing a popular post ranking using the GA4 Data API in Astro SSG.
| Item | Key point |
|---|---|
| Environment variables | Check both import.meta.env and process.env |
| Private key | Explicitly replace \n with newlines. When registering in Secret Manager, make sure the trailing END marker is included |
| Performance | Cache results to keep API calls down to once during the build |
| Fallback | Display in order of latest articles if the API fails, without stopping the build |
| Node.js version | Explicitly specify in Cloud Build, such as node:20 |
By properly understanding the SSG characteristic of "fetching data at build time and writing it out statically" and paying attention to environment differences and private key formatting, you can achieve a serverless, zero-cost popular post ranking.
At GleamHub, we undertake corporate website development and media site builds leveraging modern web technologies including Astro. If you would like to implement this on your site, please feel free to contact us.








