32blogby StudioMitsu
nextjs9 min read

Build a Popular Posts Ranking with GA4 API in Next.js

Learn how to call the Google Analytics 4 Data API from Next.js App Router to build a page-view-based popular posts ranking with ISR caching, step by step.

nextjsgoogle-analyticsga4apiisr
On this page

You want a "popular posts" section on your blog. Google Analytics 4 (GA4) already has the page view data. All you need to do is fetch it from Next.js and display it.

This article walks you through calling the GA4 Data API from Next.js App Router to build a PV-based popular posts ranking from scratch. We use ISR caching to keep API calls low while showing a near-real-time ranking.

What Is the GA4 Data API

The GA4 Data API lets you programmatically access the same data you see in the Google Analytics dashboard. Instead of opening a browser, you fetch reports from code.

The method we use is runReport, which generates reports like "which pages got the most views." It is the API equivalent of viewing the "Pages and screens" report in GA4.

It is free. For GA4 Standard (free tier) properties, the Data API has no additional cost. There is a quota of 200,000 tokens per day, but a blog ranking feature will barely use any of that.

Create a GCP Service Account

To use the GA4 Data API, you need a Google Cloud Platform (GCP) service account with access to your GA4 property. This is a one-time setup.

Prepare the GCP Project

  1. Sign in to the Google Cloud Console
  2. Use an existing project or create a new one
  3. Go to "APIs & Services" → "Library" in the left menu
  4. Search for "Google Analytics Data API" and click "Enable"

Create the Service Account

  1. Go to "APIs & Services" → "Credentials" in the left menu
  2. Click "+ Create Credentials" → "Service Account"
  3. Enter a name (e.g., ga4-reporting)
  4. Click "Create and continue." You can skip the role assignment step
  5. Click the service account you just created, then open the "Keys" tab
  6. Click "Add Key" → "Create New Key" → select "JSON" and create it

A JSON file will download. It looks like this:

json
{
  "type": "service_account",
  "project_id": "your-project-id",
  "private_key_id": "...",
  "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
  "client_email": "ga4-reporting@your-project-id.iam.gserviceaccount.com",
  "client_id": "...",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token"
}

The two fields you need are client_email and private_key.

Grant Access to Your GA4 Property

  1. Open Google Analytics
  2. Go to Admin (gear icon, bottom left) → Property → "Property Access Management"
  3. Click "+" → "Add users"
  4. Paste the client_email value from the downloaded JSON
  5. Set the role to "Viewer" (read-only access is sufficient)
  6. Click "Add"

Find Your GA4 Property ID

In the GA4 admin panel, go to "Property Settings" → "Property Details." The Property ID (numbers only, e.g., 123456789) is displayed there. You will need this value later.

Fetch GA4 Data from Next.js

With GCP configured, it is time to call the API from your Next.js project.

Install the Package

bash
npm install @google-analytics/data

Set Up Environment Variables

Add the following to .env.local:

bash
GA_PROPERTY_ID=123456789
GA_CLIENT_EMAIL=ga4-reporting@your-project-id.iam.gserviceaccount.com
GA_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEv...(truncated)...\n-----END PRIVATE KEY-----\n"

For GA_PRIVATE_KEY, paste the private_key value from the JSON file as-is. Wrap it in double quotes. The \n characters are newline escape sequences — paste them as-is. The code uses .replace(/\\n/g, '\n') to convert them to actual newlines at runtime.

Create the GA4 Client

Create lib/ga4.ts:

typescript
import { BetaAnalyticsDataClient } from "@google-analytics/data";

function getCredentials() {
  // Base64-encoded JSON key (recommended for Vercel)
  if (process.env.GA_SERVICE_KEY_BASE64) {
    return JSON.parse(
      Buffer.from(process.env.GA_SERVICE_KEY_BASE64, "base64").toString()
    );
  }
  // Fallback for local development
  return {
    client_email: process.env.GA_CLIENT_EMAIL,
    private_key: process.env.GA_PRIVATE_KEY?.replace(/\\n/g, "\n"),
  };
}

const client = new BetaAnalyticsDataClient({
  credentials: getCredentials(),
});

const propertyId = process.env.GA_PROPERTY_ID;

export type PageViewEntry = {
  path: string;
  views: number;
};

export async function getTopPages(
  limit: number = 10,
  days: number = 30
): Promise<PageViewEntry[]> {
  const [response] = await client.runReport({
    property: `properties/${propertyId}`,
    dimensions: [{ name: "pagePath" }],
    metrics: [{ name: "screenPageViews" }],
    dateRanges: [{ startDate: `${days}daysAgo`, endDate: "today" }],
    orderBys: [
      {
        metric: { metricName: "screenPageViews" },
        desc: true,
      },
    ],
    limit,
  });

  if (!response.rows) return [];

  return response.rows.map((row) => ({
    path: row.dimensionValues?.[0]?.value ?? "",
    views: parseInt(row.metricValues?.[0]?.value ?? "0", 10),
  }));
}

getCredentials() supports two authentication methods. If GA_SERVICE_KEY_BASE64 is set, it base64-decodes the full JSON key. Otherwise, it falls back to individual environment variables. The base64 approach is recommended for Vercel (explained later).

Here is what each runReport parameter does:

  • dimensions: [{ name: "pagePath" }] — group results by URL path
  • metrics: [{ name: "screenPageViews" }] — count page views
  • dateRanges — cover the last 30 days
  • orderBys — sort by views in descending order (most viewed first)
  • limit — return only the top 10 results

Now build a Server Component to display the data. With App Router Server Components, you can call async functions directly inside the component. No separate API route is needed.

typescript
import Link from "next/link";
import { getTopPages } from "@/lib/ga4";

export async function PopularPosts() {
  const pages = await getTopPages(10, 30);

  // Filter out non-article pages (home, category pages, etc.)
  const articles = pages.filter(
    (p) => p.path.match(/^\/[a-z]{2}\/[^/]+\/[^/]+$/) && p.views > 0
  );

  if (articles.length === 0) return null;

  return (
    <section>
      <h2 className="text-xl font-bold mb-4">Popular Posts</h2>
      <ol className="space-y-3">
        {articles.slice(0, 5).map((entry, i) => (
          <li key={entry.path} className="flex items-baseline gap-3">
            <span className="text-sm font-mono text-muted">{i + 1}</span>
            <Link href={entry.path} className="text-sm hover:underline">
              {entry.path}
            </Link>
            <span className="text-xs text-muted ml-auto">
              {entry.views.toLocaleString()} PV
            </span>
          </li>
        ))}
      </ol>
    </section>
  );
}

This component displays pagePath (URL paths) directly. To show article titles instead, match the slugs against your article metadata:

typescript
import { getAllArticles } from "@/lib/content";
import { getTopPages } from "@/lib/ga4";

export async function getPopularArticles(locale: string, limit: number = 5) {
  const [pages, articles] = await Promise.all([
    getTopPages(50, 30),
    Promise.resolve(getAllArticles(locale)),
  ]);

  const viewMap = new Map(pages.map((p) => [p.path, p.views]));

  return articles
    .map((article) => ({
      ...article,
      views: viewMap.get(`/${locale}/${article.category}/${article.slug}`) ?? 0,
    }))
    .filter((a) => a.views > 0)
    .sort((a, b) => b.views - a.views)
    .slice(0, limit);
}

Cache API Calls with ISR

Calling the GA4 API on every page request would waste quota and slow down responses. ISR (Incremental Static Regeneration) lets you serve cached HTML while revalidating data in the background.

Set revalidate on the page that displays the ranking:

typescript
// app/[locale]/popular/page.tsx
import { getPopularArticles } from "@/lib/ga4";

export const revalidate = 3600; // regenerate every hour

export default async function PopularPage({
  params,
}: {
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  const articles = await getPopularArticles(locale);

  return (
    <main>
      <h1>Popular Articles</h1>
      <ul>
        {articles.map((article) => (
          <li key={article.slug}>
            <a href={`/${locale}/${article.category}/${article.slug}`}>
              {article.title}
            </a>
            <span>{article.views.toLocaleString()} views</span>
          </li>
        ))}
      </ul>
    </main>
  );
}

revalidate = 3600 means "cache this page for up to 1 hour." After the cache expires, the first incoming request triggers a background regeneration, and subsequent requests get the fresh HTML.

Cache at the Function Level

If you want to cache just the GA4 API call (not the entire page), use the use cache directive. Make sure cacheComponents: true is set in next.config.ts:

typescript
import { cacheLife } from "next/cache";
import { getTopPages } from "@/lib/ga4";

export async function getCachedTopPages(limit: number, days: number) {
  "use cache";
  cacheLife("hours");
  return getTopPages(limit, days);
}

This lets multiple pages share the same cached ranking data while keeping API calls to once every few hours.

Deploy to Vercel

Locally, you stored credentials in .env.local using separate variables. On Vercel, the newline characters (\n) in private_key can cause parse errors like DECODER routines::unsupported.

The recommended approach is to base64-encode the entire JSON key file into a single environment variable. This completely avoids newline issues.

Base64 Encode the Key

Convert the downloaded JSON key file to base64:

bash
base64 -w 0 your-service-account-key.json

Copy the output string.

Set Environment Variables

In the Vercel dashboard, go to Settings → Environment Variables and add:

NameValue
GA_PROPERTY_IDYour GA4 property ID (numbers only)
GA_SERVICE_KEY_BASE64The base64-encoded string from above

No need to wrap values in quotes on Vercel. Paste them as-is.

Deploy and Verify

bash
git add .
git commit -m "feat: add GA4 popular posts ranking"
git push

Vercel will build and deploy automatically. Visit the ranking page after deployment. If data shows up, you are done.

Troubleshooting

If things are not working, check these common errors:

  • DECODER routines::unsupported: The private_key newlines failed to parse. Switch to the base64 encoding approach described above
  • PERMISSION_DENIED: Google Analytics Data API has not been used in project...: The Data API is not enabled in your GCP project. Go to "APIs & Services" → "Library" and enable it
  • PERMISSION_DENIED: User does not have sufficient permissions...: The service account email is not added as a "Viewer" in your GA4 property. Check property access management in GA4 admin
  • Environment variables not taking effect: After changing env vars on Vercel, you need to redeploy. Go to Deployments and click "Redeploy" on the latest deployment

Wrapping Up

What we built:

  • GCP service account: Created a service account in Google Cloud Console and granted Viewer access to the GA4 property
  • GA4 Data API: Used BetaAnalyticsDataClient from @google-analytics/data to fetch screenPageViews
  • Server Component: Called the API directly from an App Router Server Component — no API route needed
  • ISR caching: Used revalidate to limit API calls and conserve quota
  • Vercel deployment: Base64-encoded the JSON key into a single environment variable, avoiding newline parse issues

The GA4 Data API is free to use. The quota of 200,000 tokens per day is more than enough for a blog. Combined with ISR, you get a near-real-time ranking at zero cost.

Official resources: