Andy Simon

GTM Systems & Revenue Technology

GA4 Data API at Build Time: The Pipeline That Outlived Its Widget

by asimon
ga4google-analyticsanalyticsawslambdanextjs

A build-time analytics pipeline for a static site: GA4 Data API at build, a scheduled Lambda for freshness, the mock-data trap that could have shipped fabricated numbers, and why the pipeline is still running after its only consumer left.

The view counts this pipeline produces are not displayed anywhere on the site. The homepage widget that showed them came off first. The build-time half of the pipeline has since been retired too. What remains is the scheduled Lambda that refreshes the numbers into S3. This post is about the pipeline as built, the failure mode it grew, and which half survived.

(Updated September 2026 — rewritten around the mock-data failure mode and the pipeline's actual status. Code is unchanged; the Lambda schedule in the diagram is corrected to match the template. Updated again later that month: the build-time fetch was removed from the build; the Lambda remains.)

Why the data comes out at build time

The site has no server, so any number on a page has to be known when the page is built. That constraint decided the design: fetch view counts from GA4 during the build, write them into the data layer, and ship them as plain HTML. No credentials reach the browser, no visitor triggers an API call, and there's no loading state because there's nothing to load. The price is staleness: the number is as fresh as the last build, which is the problem the Lambda below exists to solve.

Joining GA4 data with CRM and ad-platform data is what I do professionally for marketing attribution. This is the personal-site-sized version of the same server-side pattern, with the same first question: where does the credential live, and who is allowed to hold it?

The query

GA4 exposes a server-side Data API, and access is by service account: create one in Google Cloud, add its email as a viewer on the GA4 property, and base64-encode the JSON key for storage. The encoded key lives in AWS Parameter Store, never in the repo.

# Encode the service account key
base64 -i service-account.json | tr -d '\n'

The report itself asks for pageviews grouped by path, filtered to the post slugs the build knows about:

import { google } from "googleapis";
import { GoogleAuth } from "google-auth-library";

async function getAnalyticsClient() {
  const credentials = JSON.parse(
    Buffer.from(process.env.GA4_SERVICE_ACCOUNT!, "base64").toString()
  );

  const auth = new GoogleAuth({
    credentials,
    scopes: ["https://www.googleapis.com/auth/analytics.readonly"],
  });

  return google.analyticsdata({ version: "v1beta", auth });
}

async function fetchViewCounts(postSlugs: string[]) {
  const analytics = await getAnalyticsClient();
  const propertyId = process.env.GA4_PROPERTY_ID;

  const response = await analytics.properties.runReport({
    property: `properties/${propertyId}`,
    requestBody: {
      dateRanges: [{ startDate: "2020-01-01", endDate: "today" }],
      dimensions: [{ name: "pagePath" }],
      metrics: [{ name: "screenPageViews" }],
      dimensionFilter: {
        andGroup: {
          expressions: [{
            filter: {
              fieldName: "pagePath",
              inListFilter: {
                values: postSlugs.map(slug => `/${slug}`),
              },
            },
          }],
        },
      },
    },
  });

  // Parse response into { slug: viewCount } map
  const viewCounts: Record<string, number> = {};
  response.data.rows?.forEach(row => {
    const path = row.dimensionValues?.[0]?.value;
    const views = parseInt(row.metricValues?.[0]?.value || "0", 10);
    if (path?.startsWith("/")) {
      viewCounts[path.substring(1)] = views;
    }
  });

  return viewCounts;
}

Two details cost me time: inListFilter is what lets one request cover every post instead of one request per post, and the metric is screenPageViews, not the Universal Analytics pageviews that most search results still show.

The build step (retired)

This is the step that was retired. It is kept here because the failure mode below lived in it. A script ran before Next.js, read the post slugs from the content directory, fetched counts, and wrote a JSON snapshot:

// scripts/generate-view-counts.mjs
import fs from "fs";
import path from "path";

async function main() {
  // Read post slugs from content directory
  const contentDir = path.join(process.cwd(), "src/content");
  const postSlugs = fs.readdirSync(contentDir)
    .filter(file => file.endsWith(".mdx"))
    .map(file => file.replace(/\.mdx$/, ""));

  // Fetch from GA4
  const viewCounts = await fetchViewCountsFromGA(postSlugs);

  // Write to JSON file
  const outputPath = path.join(process.cwd(), "src/data/view-counts.json");
  fs.writeFileSync(outputPath, JSON.stringify({
    viewCounts,
    generated: new Date().toISOString(),
    source: "Google Analytics 4 Data API",
  }, null, 2));
}
{
  "scripts": {
    "prebuild": "node ./scripts/generate-view-counts.mjs",
    "build": "next build"
  }
}

The snapshot file was committed. That decision is what made the next section matter.

The failure mode: fabricated numbers that look real

The script failed the build if GA4 couldn't be reached. It had one override, a flag that substitutes mock counts, and the override exists because a local static build has no GA4 credentials and still needs a file to read. The mock generator is deterministic on purpose, so the numbers don't jump between runs:

// Fail-safe mock data for development
export function generateMockViewCounts(slugs: string[]) {
  return Object.fromEntries(
    slugs.map(slug => {
      // Deterministic hash so counts don't jump around
      const hash = createHash("sha256").update(slug).digest("hex");
      const views = 100 + (parseInt(hash.slice(0, 8), 16) % 5000);
      return [slug, views];
    })
  );
}

Put those two facts together. A local build with the flag set overwrites the committed snapshot with hashed, plausible-looking, stable numbers. In a diff they look like real traffic that moved a little. Commit without noticing and the repo's data layer now carries invented view counts with a generated timestamp that says they're fresh.

Nothing on the site displayed them, which is the only reason this is a near-miss story rather than a correction. The rule that came out of it was written in bold in the repo's agent guidelines: after any local build with mock data, restore the committed snapshot before committing. The site's editorial rules say the same thing about every number on it. Displaying an invented figure is an integrity failure, and a pipeline that can manufacture one silently is a pipeline that needs a rule attached.

A rule attached to a step nobody needed was the wrong fix, and it lasted about two weeks. Retiring the build step closed the hazard for good: there is no committed snapshot left to overwrite, no mock flag, no GA4 call in the deploy, and one fewer secret loaded per run. The guideline that replaced the rule is shorter: never reintroduce a step that can write fabricated numbers into the data layer.

Freshness: a scheduled Lambda

Build-time data is as old as the last deploy. Three ways to fix that:

| Approach | Freshness | Complexity | Cost | |----------|-----------|------------|------| | Deploy more often | Hours | Low | CI minutes | | Client-side fetch | Real-time | Medium | API calls | | Lambda refresh | Minutes | Medium | Cents |

I chose the Lambda because it keeps the static property intact: the browser still fetches a plain file, it's just a file that a function rewrites on a schedule.

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│ EventBridge │────▶│   Lambda    │────▶│     S3      │
│  (30 min)   │     │  ga4-sync   │     │  /api/views │
└─────────────┘     └─────────────┘     └─────────────┘
                                               │
                                               ▼
                                        ┌─────────────┐
                                        │ CloudFront  │
                                        │  (5m TTL)   │
                                        └─────────────┘

The schedule is thirty minutes. The original version of this post said five, and the template never did: it has said thirty since a configuration fix in December 2025, before this post was first published. The diagram now says what the template says, which is the only defensible source.

// src/functions/ga4-sync.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";

const s3 = new S3Client({ region: "us-east-2" });
const ssm = new SSMClient({ region: "us-east-2" });

export async function handler() {
  const bucket = process.env.VIEW_COUNT_BUCKET;
  const postSlugs = await resolvePostSlugs();

  // Load GA4 credentials from Parameter Store
  const ga4PropertyId = await getParameter("/asimon-blog/prod/ga4-property-id");
  const ga4ServiceAccount = await getParameter(
    "/asimon-blog/prod/ga4-service-account",
    true // decrypt
  );

  // Fetch current counts
  const counts = await fetchViewCountsFromGA(postSlugs, {
    env: { GA4_PROPERTY_ID: ga4PropertyId, GA4_SERVICE_ACCOUNT: ga4ServiceAccount }
  });

  // Write individual JSON files
  const timestamp = new Date().toISOString();
  await Promise.all(
    Object.entries(counts).map(([slug, views]) =>
      s3.send(new PutObjectCommand({
        Bucket: bucket,
        Key: `api/views/${slug}.json`,
        Body: JSON.stringify({ views, updated: timestamp }),
        ContentType: "application/json",
        CacheControl: "public, max-age=300",
      }))
    )
  );
}

One honest limit sits in resolvePostSlugs(). The Lambda has no content directory to read, so it takes its slug list from an environment variable in the CloudFormation template, maintained by hand. That list is stale right now: it predates every 2026 post. The consequence is small (new posts don't get a refreshed file until the list is updated) and the fix is not: editing that variable changes the template, and the deploy pipeline's drift check blocks every deploy until the stack is redeployed to match. The pipeline post covers that trap. I've left the list stale on purpose until there's a consumer that needs it current.

The original version of this post carried a cost table for the Lambda, worked out at a five-minute schedule and eight posts. Both inputs have changed and I don't have a bill in front of me, so the table is gone rather than recomputed. At the five-minute schedule the table assumed, it came to about $0.04 a month; the real schedule runs a sixth as often.

What survives a failure

  1. GA4 API fails → the Lambda logs the error and leaves the previous files in S3.
  2. Lambda fails → CloudFront keeps serving the last files it cached.

The consumer of those files used to be a small React hook on the homepage. It's retired. What remains is the endpoint contract: one small JSON file per post, cacheable, no server. Any future consumer (a dashboard, the site's terminal, a widget that earns its place back) inherits that contract unchanged.

Two security decisions

The service account key is stored encrypted in Parameter Store and read at run time by the Lambda; it is not in the repo and not in GitHub Secrets. The S3 bucket is private, with CloudFront reaching it through Origin Access Control, so the /api/views files are public only through the CDN. The Lambda's role can read the two parameters and write the one prefix. Everything else about the setup is convention.

Why it's still running

By my own rule from the buy-vs-build post, a capability nobody operates is a liability with an invoice. This one survives that rule on two counts. Its invoice is a rounding error, and its contract is the reusable piece: the scheduled refresh and the degradation ladder are what any future data-on-a-static-site feature would be built on. The drift check keeps its infrastructure honest, and retiring the build step removed the only path that could make it lie.

It also earned this post. A pipeline that can quietly fabricate numbers, and a site whose editorial rules forbid a single invented figure, are a combination worth writing down before the widget comes back.

Next up: S3 + CloudFront Deploys reads the CI/CD pipeline that runs this at every push, gate by gate.