Popular Post Ranking on Astro Static Sites with GA4

3 min read
Modified

Running a static site often leads to a classic dilemma: how to handle dynamic content. Popular post rankings, driven by view counts, are a prime example—they go stale the moment you stop updating them.

While fetching data via client-side JavaScript is the standard “easy” path, it often comes at the cost of performance, privacy, and API quota management. Instead, I opted for a build-time integration with the Google Analytics 4 (GA4) API. In this post, I’ll dive into how I built a robust pipeline that keeps rankings fresh, ensures data integrity, and handles CI failures gracefully.

Architecture: Why Build-Time?

At its core, this site fetches GA4 data during the build process and saves it as a JSON file.

I avoided client-side fetching for a few simple reasons:

  1. Performance: I don’t want to run unnecessary JS in the user’s browser.
  2. Security: It’s practically impossible considering credential management in the browser.
  3. Robustness: The GA4 API has strict quotas. Relying on it at runtime means the widget could break if traffic spikes.
  4. Privacy: Keeping the API communication on the server/CI side reduces the footprint left in the visitor’s browser.

By baking the data into the build, users get pure, static HTML. It’s lightning fast.

The Fetch Logic: Pulling Data from GA4

I use the @google-analytics/data package to fetch popular posts, running a script just before astro build.

Here’s an example of fetching simply by page views. You could potentially create even better rankings by factoring in metrics like average engagement time.

// scripts/fetch-popular-posts.ts (Simplified)
const [response] = await client.runReport({
property: `properties/${propertyId}`,
dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }],
dimensions: [{ name: 'pagePath' }, { name: 'pageTitle' }],
metrics: [{ name: 'screenPageViews' }],
orderBys: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
limit: 50,
});

I fetch the top 50 posts from the last 30 days.

Ensuring Content Integrity

This is the tricky part. GA4 data is essentially a historical record. It doesn’t know if you’ve:

  • Deleted a post.
  • Changed a post’s slug (URL).
  • Updated a title.

If you blindly display GA4 data, you’ll end up with 404 links or stale titles. To fix this, the system reconciles the GA4 snapshot against the live markdown collection.

src/lib/popular-posts.ts
export async function getValidatedPopularPosts(lang: Lang, limit = 5) {
const rawData = // ... fetched JSON
const livePosts = await getPostsByLang(lang); // All current MD files
const titleBySlug = new Map(
livePosts.map((p) => [getSlugFromId(p.id), p.data.title])
);
// Only keep posts that still exist in our content directory
return rawData.posts
.filter(p => titleBySlug.has(p.slug))
.map(p => ({
...p,
title: titleBySlug.get(p.slug) // Use the latest title from Markdown
}))
.slice(0, limit);
}

It might be a bit of a brute-force approach, but it ensures that we only show posts that actually exist, even if they’ve been renamed or updated.

CI: Don’t Break the Build

When building in environments like GitHub Actions, you need GA4 secrets. However, there are risks: secrets might not be available (like in PR builds), keys might expire, or GA4 itself might be down.

The last thing you want is for a build to fail its CI check just because the GA4 API wasn’t reachable.

I implemented a “Graceful Degradation” strategy:

  1. If secrets are missing or the API call fails…
  2. Check if a previous popular-posts.json exists and keep it.
  3. If no snapshot exists at all, write an empty JSON object so the build doesn’t crash.
function fallback(reason: string): void {
if (existsSync(OUTPUT_PATH)) {
console.warn(`[fetch-popular-posts] ${reason} — Preserving existing snapshot.`);
return;
}
// ... write empty JSON
}

This allows the build to continue using cached data whenever the API is unreachable.

Wrap-up: Embracing the Static-Dynamic Hybrid

You don’t have to choose between “pure static” and “dynamic JS-heavy.” By utilizing the build process as an intermediate layer, you can bring dynamic features to life without sacrificing the benefits of SSG.

It takes a bit of extra plumbing, but the result is a rock-solid, high-performance feature that “just works.” And honestly, watching your site automatically reorganize itself based on real traffic data is just satisfying.

If you’re running a static site, give this build-time integration a shot!