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:
- Performance: I don’t want to run unnecessary JS in the user’s browser.
- Security: It’s practically impossible considering credential management in the browser.
- Robustness: The GA4 API has strict quotas. Relying on it at runtime means the widget could break if traffic spikes.
- 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.
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:
- If secrets are missing or the API call fails…
- Check if a previous
popular-posts.jsonexists and keep it. - 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!






