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
- Sign in to the Google Cloud Console
- Use an existing project or create a new one
- Go to "APIs & Services" → "Library" in the left menu
- Search for "Google Analytics Data API" and click "Enable"
Create the Service Account
- Go to "APIs & Services" → "Credentials" in the left menu
- Click "+ Create Credentials" → "Service Account"
- Enter a name (e.g.,
ga4-reporting) - Click "Create and continue." You can skip the role assignment step
- Click the service account you just created, then open the "Keys" tab
- Click "Add Key" → "Create New Key" → select "JSON" and create it
A JSON file will download. It looks like this:
{
"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
- Open Google Analytics
- Go to Admin (gear icon, bottom left) → Property → "Property Access Management"
- Click "+" → "Add users"
- Paste the
client_emailvalue from the downloaded JSON - Set the role to "Viewer" (read-only access is sufficient)
- 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
npm install @google-analytics/data
Set Up Environment Variables
Add the following to .env.local:
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:
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 pathmetrics: [{ name: "screenPageViews" }]— count page viewsdateRanges— cover the last 30 daysorderBys— sort by views in descending order (most viewed first)limit— return only the top 10 results
Build a Popular Posts Component
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.
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:
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:
// 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:
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:
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:
| Name | Value |
|---|---|
GA_PROPERTY_ID | Your GA4 property ID (numbers only) |
GA_SERVICE_KEY_BASE64 | The base64-encoded string from above |
No need to wrap values in quotes on Vercel. Paste them as-is.
Deploy and Verify
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: Theprivate_keynewlines failed to parse. Switch to the base64 encoding approach described abovePERMISSION_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 itPERMISSION_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
BetaAnalyticsDataClientfrom@google-analytics/datato fetchscreenPageViews - Server Component: Called the API directly from an App Router Server Component — no API route needed
- ISR caching: Used
revalidateto 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:
- GA4 Data API Overview — API overview and guides
- Dimensions & Metrics — available dimensions and metrics
- Data API Quotas — quota limits and details
- @google-analytics/data — Node.js client library