GA4 Data API w Next.js – budujemy własny dashboard analityczny

GA4 Data API w Next.js bez skrótów myślowych: service account, cache, limity, bezpieczeństwo i budowa własnego dashboardu na danych z Analytics.

Opublikowano

31 sierpnia 2025 11:30

Czytanie

4 min czytania

Aktualizacja

7 kwietnia 2026 10:47

Interfejs GA4, czyli Google Analytics 4, to aktualna wersja platformy analitycznej Google do pomiaru zdarzeń i zachowań użytkowników. jest potężny, ale dla wielu klientów i zespołów bywa po prostu zbyt techniczny. Zamiast wysyłać klientowi screenshoty z GA4, możesz zbudować własny dashboard w Next.js, który pobiera dane bezpośrednio z GA4 Data API, czyli Application Programming Interface, definiuje sposób komunikacji między aplikacjami lub modułami. i prezentuje je w czytelnej formie.

Krótka odpowiedź: Własny dashboard analityczny w Next.js oparty na GA4 Data API wymaga Service Account z minimalnymi uprawnieniami, komunikacji wyłącznie po stronie serwera i cache'owania odpowiedzi od pierwszego dnia — API ma limity tokenowe. GA4 to dobre źródło agregatów i trendów, ale nie surowego event streamu. Kluczowa metryka w nowym API to keyEvents, nie dawne conversions.

Kiedy warto budować custom dashboard?

Kilka scenariuszy, w których custom dashboard ma sens: klient chce widzieć metryki bez logowania do GA4, potrzebujesz połączyć dane GA4 z innymi źródłami (CRM, baza danych), chcesz prezentować dane w kontekście biznesowym (nie technicznym) lub budujesz admin panel z wbudowaną analityką.

Krok 1: Autoryzacja – Service Account

GA4 Data API wymaga autoryzacji OAuth2. Najprościej przez Service Account.

Utworzenie Service Account

  1. Google Cloud Console → APIs & Services → Credentials,
  2. Create Credentials → Service Account,
  3. Pobierz klucz JSON,
  4. W GA4: Admin → Property Access Management → dodaj email Service Account z rolą Viewer albo inną minimalną rolą pozwalającą odczytywać dane.

Environment variables

Code
# .env.local
GA4_PROPERTY_ID=properties/123456789
GA4_CLIENT_EMAIL=ga4-reader@project-id.iam.gserviceaccount.com
GA4_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----\n"

Instalacja

Code
npm install @google-analytics/data

Krok 2: GA4 Client – server-side

Code
// lib/ga4/client.ts
import { BetaAnalyticsDataClient } from '@google-analytics/data'
 
let client: BetaAnalyticsDataClient | null = null
 
export function getGA4Client(): BetaAnalyticsDataClient {
  if (!client) {
    client = new BetaAnalyticsDataClient({
      credentials: {
        client_email: process.env.GA4_CLIENT_EMAIL,
        private_key: process.env.GA4_PRIVATE_KEY?.replace(/\\n/g, '\n'),
      },
    })
  }
  return client
}
 
export const GA4_PROPERTY_ID = process.env.GA4_PROPERTY_ID!

Krok 3: Funkcje do pobierania danych

Code
// lib/ga4/queries.ts
import { getGA4Client, GA4_PROPERTY_ID } from './client'
 
export interface AnalyticsOverview {
  totalUsers: number
  newUsers: number
  sessions: number
  pageViews: number
  avgSessionDuration: number
  bounceRate: number
}
 
export async function getOverview(
  startDate: string,
  endDate: string,
): Promise<AnalyticsOverview> {
  const client = getGA4Client()
 
  const [response] = await client.runReport({
    property: GA4_PROPERTY_ID,
    dateRanges: [{ startDate, endDate }],
    metrics: [
      { name: 'totalUsers' },
      { name: 'newUsers' },
      { name: 'sessions' },
      { name: 'screenPageViews' },
      { name: 'averageSessionDuration' },
      { name: 'bounceRate' },
    ],
  })
 
  const row = response.rows?.[0]
  const values = row?.metricValues ?? []
 
  return {
    totalUsers: parseInt(values[0]?.value ?? '0'),
    newUsers: parseInt(values[1]?.value ?? '0'),
    sessions: parseInt(values[2]?.value ?? '0'),
    pageViews: parseInt(values[3]?.value ?? '0'),
    avgSessionDuration: parseFloat(values[4]?.value ?? '0'),
    bounceRate: parseFloat(values[5]?.value ?? '0'),
  }
}
 
export interface DailyMetric {
  date: string
  users: number
  sessions: number
  pageViews: number
}
 
export async function getDailyMetrics(
  startDate: string,
  endDate: string,
): Promise<DailyMetric[]> {
  const client = getGA4Client()
 
  const [response] = await client.runReport({
    property: GA4_PROPERTY_ID,
    dateRanges: [{ startDate, endDate }],
    dimensions: [{ name: 'date' }],
    metrics: [
      { name: 'totalUsers' },
      { name: 'sessions' },
      { name: 'screenPageViews' },
    ],
    orderBys: [
      { dimension: { dimensionName: 'date', orderType: 'ALPHANUMERIC' } },
    ],
  })
 
  return (response.rows ?? []).map((row) => ({
    date: formatGA4Date(row.dimensionValues?.[0]?.value ?? ''),
    users: parseInt(row.metricValues?.[0]?.value ?? '0'),
    sessions: parseInt(row.metricValues?.[1]?.value ?? '0'),
    pageViews: parseInt(row.metricValues?.[2]?.value ?? '0'),
  }))
}
 
export interface TopPage {
  path: string
  title: string
  pageViews: number
  totalEngagementTime: number // suma czasu zaangażowania w sekundach (userEngagementDuration)
}
 
export async function getTopPages(
  startDate: string,
  endDate: string,
  limit: number = 10,
): Promise<TopPage[]> {
  const client = getGA4Client()
 
  const [response] = await client.runReport({
    property: GA4_PROPERTY_ID,
    dateRanges: [{ startDate, endDate }],
    dimensions: [{ name: 'pagePath' }, { name: 'pageTitle' }],
    metrics: [{ name: 'screenPageViews' }, { name: 'userEngagementDuration' }],
    orderBys: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
    limit,
  })
 
  return (response.rows ?? []).map((row) => ({
    path: row.dimensionValues?.[0]?.value ?? '',
    title: row.dimensionValues?.[1]?.value ?? '',
    pageViews: parseInt(row.metricValues?.[0]?.value ?? '0'),
    totalEngagementTime: parseFloat(row.metricValues?.[1]?.value ?? '0'),
  }))
}
 
export interface TrafficSource {
  source: string
  medium: string
  users: number
  sessions: number
  keyEvents: number
}
 
export async function getTrafficSources(
  startDate: string,
  endDate: string,
): Promise<TrafficSource[]> {
  const client = getGA4Client()
 
  const [response] = await client.runReport({
    property: GA4_PROPERTY_ID,
    dateRanges: [{ startDate, endDate }],
    dimensions: [{ name: 'sessionSource' }, { name: 'sessionMedium' }],
    metrics: [
      { name: 'totalUsers' },
      { name: 'sessions' },
      { name: 'keyEvents' },
    ],
    orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
    limit: 15,
  })
 
  return (response.rows ?? []).map((row) => ({
    source: row.dimensionValues?.[0]?.value ?? '(direct)',
    medium: row.dimensionValues?.[1]?.value ?? '(none)',
    users: parseInt(row.metricValues?.[0]?.value ?? '0'),
    sessions: parseInt(row.metricValues?.[1]?.value ?? '0'),
    keyEvents: parseInt(row.metricValues?.[2]?.value ?? '0'),
  }))
}
 
// Helper: GA4 zwraca daty jako "20260219"
function formatGA4Date(dateStr: string): string {
  if (dateStr.length !== 8) return dateStr
  return `${dateStr.slice(0, 4)}-${dateStr.slice(4, 6)}-${dateStr.slice(6, 8)}`
}

Krok 4: API Routes z cache

GA4 Data API ma limity i kwoty tokenowe, więc cache nie jest tu optymalizacją "na potem", tylko częścią poprawnej architektury od pierwszego dnia.

Code
// app/api/analytics/overview/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getOverview } from '@/lib/ga4/queries'
 
export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl
  const startDate = searchParams.get('startDate') ?? '30daysAgo'
  const endDate = searchParams.get('endDate') ?? 'today'
 
  try {
    const data = await getOverview(startDate, endDate)
 
    return NextResponse.json(data, {
      headers: {
        // Cache na 5 minut – dane analityczne nie muszą być real-time
        'Cache-Control': 's-maxage=300, stale-while-revalidate=600',
      },
    })
  } catch (error) {
    console.error('GA4 API error:', error)
    return NextResponse.json(
      { error: 'Failed to fetch analytics' },
      { status: 500 },
    )
  }
}

Alternatywa: Server Components z ISR

Code
// app/dashboard/page.tsx
import { getOverview, getDailyMetrics, getTopPages } from "@/lib/ga4/queries";
import { DashboardClient } from "./DashboardClient";
 
// Revalidate co 5 minut
export const revalidate = 300;
 
export default async function DashboardPage() {
  const [overview, dailyMetrics, topPages] = await Promise.all([
    getOverview("30daysAgo", "today"),
    getDailyMetrics("30daysAgo", "today"),
    getTopPages("30daysAgo", "today"),
  ]);
 
  return (
    <DashboardClient
      overview={overview}
      dailyMetrics={dailyMetrics}
      topPages={topPages}
    />
  );
}

Krok 5: Frontend – wizualizacja z Recharts

Code
// app/dashboard/DashboardClient.tsx
"use client";
 
import {
  LineChart,
  Line,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
  BarChart,
  Bar,
} from "recharts";
import type {
  AnalyticsOverview,
  DailyMetric,
  TopPage,
} from "@/lib/ga4/queries";
 
interface DashboardProps {
  overview: AnalyticsOverview;
  dailyMetrics: DailyMetric[];
  topPages: TopPage[];
}
 
export function DashboardClient({
  overview,
  dailyMetrics,
  topPages,
}: DashboardProps) {
  return (
    <div className="dashboard">
      <h1>Analytics Dashboard</h1>
 
      {/* KPI Cards */}
      <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
        <KPICard
          title="Użytkownicy"
          value={overview.totalUsers.toLocaleString("pl-PL")}
        />
        <KPICard
          title="Sesje"
          value={overview.sessions.toLocaleString("pl-PL")}
        />
        <KPICard
          title="Odsłony"
          value={overview.pageViews.toLocaleString("pl-PL")}
        />
        <KPICard
          title="Śr. czas sesji"
          value={`${Math.round(overview.avgSessionDuration)}s`}
        />
      </div>
 
      {/* Daily Users Chart */}
      <section className="mt-8">
        <h2>Użytkownicy dziennie</h2>
        <ResponsiveContainer width="100%" height={300}>
          <LineChart data={dailyMetrics}>
            <CartesianGrid strokeDasharray="3 3" />
            <XAxis
              dataKey="date"
              tickFormatter={(d) => d.slice(5)} // "02-19"
            />
            <YAxis />
            <Tooltip />
            <Line
              type="monotone"
              dataKey="users"
              stroke="#4f46e5"
              strokeWidth={2}
              dot={false}
            />
            <Line
              type="monotone"
              dataKey="sessions"
              stroke="#10b981"
              strokeWidth={2}
              dot={false}
            />
          </LineChart>
        </ResponsiveContainer>
      </section>
 
      {/* Top Pages */}
      <section className="mt-8">
        <h2>Top strony</h2>
        <ResponsiveContainer width="100%" height={300}>
          <BarChart data={topPages.slice(0, 8)} layout="vertical">
            <CartesianGrid strokeDasharray="3 3" />
            <XAxis type="number" />
            <YAxis
              type="category"
              dataKey="path"
              width={200}
              tick={{ fontSize: 12 }}
            />
            <Tooltip />
            <Bar dataKey="pageViews" fill="#4f46e5" />
          </BarChart>
        </ResponsiveContainer>
      </section>
    </div>
  );
}
 
function KPICard({ title, value }: { title: string; value: string }) {
  return (
    <div className="rounded-lg border p-4">
      <p className="text-sm text-gray-500">{title}</p>
      <p className="text-2xl font-bold">{value}</p>
    </div>
  );
}

Krok 6: Date Range Picker

useSearchParams() w Next.js App Router wymaga opakowania komponentu w <Suspense>, ponieważ inaczej Next.js wymusi dynamiczne renderowanie całej strony nadrzędnej lub rzuci błąd podczas buildu.

Code
// app/dashboard/page.tsx (fragment)
import { Suspense } from 'react'
import { DateRangePicker } from '@/components/DateRangePicker'
 
// ...w JSX dashboardu:
;<Suspense
  fallback={<div className="h-8 w-64 animate-pulse rounded bg-gray-100" />}
>
  <DateRangePicker />
</Suspense>
Code
// components/DateRangePicker.tsx
"use client";
 
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
 
const presets = [
  { label: "7 dni", startDate: "7daysAgo", endDate: "today" },
  { label: "30 dni", startDate: "30daysAgo", endDate: "today" },
  { label: "90 dni", startDate: "90daysAgo", endDate: "today" },
  { label: "12 miesięcy", startDate: "365daysAgo", endDate: "today" },
];
 
export function DateRangePicker() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [active, setActive] = useState(
    searchParams.get("range") ?? "30daysAgo"
  );
 
  const handleSelect = (preset: (typeof presets)[0]) => {
    setActive(preset.startDate);
    const params = new URLSearchParams({
      startDate: preset.startDate,
      endDate: preset.endDate,
    });
    router.push(`/dashboard?${params.toString()}`);
  };
 
  return (
    <div className="flex gap-2">
      {presets.map((preset) => (
        <button
          key={preset.startDate}
          onClick={() => handleSelect(preset)}
          className={
            active === preset.startDate
              ? "bg-indigo-600 text-white px-3 py-1 rounded"
              : "bg-gray-100 px-3 py-1 rounded"
          }
        >
          {preset.label}
        </button>
      ))}
    </div>
  );
}

Zaawansowane query – porównanie okresów

Code
// lib/ga4/queries.ts – dodatkowa funkcja
export async function getOverviewWithComparison(
  startDate: string,
  endDate: string,
  compareStartDate: string,
  compareEndDate: string,
) {
  const client = getGA4Client()
 
  const [response] = await client.runReport({
    property: GA4_PROPERTY_ID,
    dateRanges: [
      { startDate, endDate, name: 'current' },
      {
        startDate: compareStartDate,
        endDate: compareEndDate,
        name: 'previous',
      },
    ],
    dimensions: [{ name: 'dateRange' }],
    metrics: [
      { name: 'totalUsers' },
      { name: 'sessions' },
      { name: 'screenPageViews' },
    ],
  })
 
  // GA4 zwraca osobny wiersz per dateRange — rozróżniamy po nazwie nadanej w dateRanges
  const currentRow = response.rows?.find(
    (row) => row.dimensionValues?.[0]?.value === 'current',
  )
  const previousRow = response.rows?.find(
    (row) => row.dimensionValues?.[0]?.value === 'previous',
  )
  const current = currentRow?.metricValues ?? []
  const previous = previousRow?.metricValues ?? []
 
  function calcChange(curr: string, prev: string): number {
    const c = parseInt(curr || '0')
    const p = parseInt(prev || '0')
    if (p === 0) return 0
    return Math.round(((c - p) / p) * 100)
  }
 
  return {
    users: {
      current: parseInt(current[0]?.value ?? '0'),
      previous: parseInt(previous[0]?.value ?? '0'),
      change: calcChange(current[0]?.value ?? '0', previous[0]?.value ?? '0'),
    },
    sessions: {
      current: parseInt(current[1]?.value ?? '0'),
      previous: parseInt(previous[1]?.value ?? '0'),
      change: calcChange(current[1]?.value ?? '0', previous[1]?.value ?? '0'),
    },
    pageViews: {
      current: parseInt(current[2]?.value ?? '0'),
      previous: parseInt(previous[2]?.value ?? '0'),
      change: calcChange(current[2]?.value ?? '0', previous[2]?.value ?? '0'),
    },
  }
}

Kluczowym detalem jest że przy wielu dateRanges dodajemy wymiar dateRange, bo to on pozwala później rozróżnić, który wiersz należy do okresu current, a który do previous.

Bezpieczeństwo i rate limits

Nigdy nie eksponuj Service Account credentials na frontendzie. Wszystkie zapytania do GA4 Data API muszą iść przez server-side (API Routes, Server Components, Server Actions).

Rate limits: GA4 Data API używa kwot tokenowych zależnych od typu zapytań i właściwości, czyli w praktyce cache jest częścią poprawnej architektury, a nie późniejszą optymalizacją. Poczujesz różnice, jak dashboard zrobi więcej niż kilka prostych raportów.

Autoryzacja dashboardu: Zabezpiecz /dashboard middleware'em lub server-side auth check. Nie chcesz, żeby każdy widział Twoje dane.

Code
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')
 
  // Sprawdź istnienie i podstawowy format tokenu.
  // W produkcji zastąp tę weryfikację sprawdzeniem podpisu JWT
  // (np. jose.jwtVerify) lub wywołaniem własnego endpointu sesji.
  if (!token?.value) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
}
 
// Middleware uruchamia się TYLKO dla ścieżek /dashboard/*.
// Bez tego konfiguratora Next.js odpala middleware na każdym żądaniu
// (statyki, fonty, API), co jest zbędnym kosztem.
export const config = {
  matcher: ['/dashboard/:path*'],
}

FAQ

Czy mogę odpytywać GA4 Data API bezpośrednio z frontendu?

Nie. Klucze Service Account i dostęp do właściwości muszą zostać po stronie serwera. Frontend powinien rozmawiać wyłącznie z Twoim własnym backendem, Route Handlerem albo Server Componentem.

Czy Service Account potrzebuje roli Editor?

Zwykle nie, a do samych odczytów wystarczą minimalne uprawnienia na poziomie właściwości, najczęściej Viewer albo inna rola, która pozwala pobierać potrzebne raporty. Nadawanie zbyt szerokich uprawnień bywa niebezpieczne i nie jest też optymalne do działań operacyjnych.

Czy keyEvents to to samo co dawne conversions?

W praktyce to aktualna nazwa i model w GA4, a jeśli pracujesz na nowszej dokumentacji, raportach i Data API, myśl raczej kategorią key events. To ważne także dlatego, że starsze nazewnictwo łatwo miesza się z Google Ads i klasycznym „conversion tracking”.

Jak często cache'ować taki dashboard?

Najczęściej 10-15 minut to dobry punkt startowy, ponieważ dane analityczne rzadko wymagają twardego real-time, a agresywne cache'owanie oszczędza kwoty API i stabilizuje działanie dashboardu.

Jak zabezpieczyć dashboard przed nieautoryzowanym dostępem?

Zabezpiecz ścieżkę /dashboard middlewarem Next.js z weryfikacją sesji lub tokenu przed renderowaniem strony, ponieważ żadne dane z GA4 nie powinny być dostępne publicznie. Mam tu na myśli zarówno dane agregowane jak i surowe raporty mogą ujawniać bardzo wrażliwe informacje o biznesie.

Jakie metryki i wymiary są dostępne w GA4 Data API?

GA4 Data API udostępnia setki wymiarów i metryk, m.in. totalUsers, sessions, screenPageViews, bounceRate, averageSessionDuration oraz wymiary jak pagePath, sessionSource, deviceCategory. Pełna lista dostępna jest w dokumentacji Google Analytics Data API schema. Nie wszystkie kombinacje są kompatybilne — warto weryfikować zapytania testowo.

Czy GA4 Data API nadaje się do raportowania w czasie rzeczywistym?

Dane w standardowych raportach Data API mają opóźnienie rzędu kilku minut do kilku godzin, a do wyświetlania metryk bliskich real-time istnieje osobne Realtime API w GA4, które zwraca dane z ostatnich 30 minut, ale ma inne możliwości i ograniczenia niż główne Data API.

Podsumowanie

GA4 Data API w połączeniu z Next.js Server Components to potężna i bardzo pożyteczna kombinacja. Masz szerokie możliwości działania: możesz budować dashboardy dostosowane do potrzeb biznesu, łączyć dane analityczne z wewnętrznymi systemami i prezentować insights w formie zrozumiałej dla każdego i to bez konieczności logowania do GA4.

Po prostu zacznij od prostego overview z KPI cards i chart, a następnie rozbodowuj wszystko stopniowo, ale przede wszystkim zajmij się kwestiami bezpieczeństwa - one są sprawą fundamentalną przy budowie dashboardu analitycznego.

Źródła i dokumentacja

Pracuję z tym zawodowo.

Jeśli chcesz połączyć SEO, analitykę, Google Ads i warstwę techniczną strony w jeden sensowny system wzrostu, skontaktuj się ze mną. Pomagam układać wdrożenia, które nie kończą się na samym tagowaniu, ale wspierają widoczność, pomiar i konwersję.

O autorze

Maciej Sala

Maciej Sala — project manager i frontendowiec z doświadczeniem w marketingu internetowym. Na co dzień pracuję z Reactem, Next.js i TypeScriptem, łącząc perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat związany z branżą gier wideo jako project manager i game designer.

Absolwent historii na Uniwersytecie Jagiellońskim i studiów podyplomowych z marketingu internetowego na Akademii Górniczo-Hutniczej w Krakowie. Poza pracą trenuje na siłowni, maluje figurki i realizuje własne projekty.

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów
Astro.js vs Next.js — które narzędzie wybrać w 2026 roku?

Astro.js vs Next.js — które narzędzie wybrać w 2026 roku?

Fachowe porównanie Astro.js i Next.js z perspektywy developera pracującego na co dzień w Next.js. Architektura, wydajność, SEO, DX, koszty i konkretne use case — z benchmarkami i przykładami kodu.

Maciej Sala

Maciej Sala

Founder Strivelab