Przejdź do treści

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.

Maciej Sala

Founder StriveLab

8 min czytaniaOpublikowano 31 sierpnia 2025 (Aktualizacja 27 maja 2026)

Kiedy warto budować własny dashboard GA4?

to narzędzie zaprojektowane dla analityków, a nie dla klientów — interfejs jest gęsty, terminologia mocno techniczna, a dostęp wymaga konta Google z uprawnieniami do właściwości. Dla większości klientów to bariera zbyt wysoka, żeby samodzielnie z niego korzystać.

Custom dashboard w Next.js rozwiązuje kilka konkretnych problemów. Klient widzi wybrane metryki w kontekście swojego biznesu, bez konieczności logowania do GA4 i rozumienia, czym jest "sessionMedium". Możesz połączyć dane GA4 z innymi źródłami — zamówieniami z -a, leadami z CRM-a, przychodami z własnej bazy — i pokazać je razem w jednym widoku. Możesz również zbudować publiczny lub wewnętrzny panel z wbudowaną analityką, który jest integralną częścią produktu, a nie zewnętrznym narzędziem.

Warto jednak wiedzieć, kiedy to podejście nie ma sensu: jeśli potrzebujesz pełnej elastyczności eksploracji danych, zaawansowanych segmentów i porównań, GA4 (lub Looker Studio z konektorem GA4) będzie szybsze i tańsze w utrzymaniu. Custom dashboard opłaca się wtedy, gdy wiesz z góry, jakie metryki chcesz pokazywać i komu.

Krok 1: autoryzacja GA4 Data API przez Service Account

GA4 Data wymaga autoryzacji OAuth2. Dla aplikacji server-side właściwym wyborem jest Service Account — konto techniczne bez hasła, identyfikowane kluczem JSON. Nie używa interaktywnego logowania, nie wymaga odświeżania tokenów przez użytkownika i daje się bezpiecznie trzymać w zmiennych środowiskowych serwera.

Alternatywą jest OAuth2 z tokenami użytkownika, ale to ma sens tylko wtedy, gdy chcesz, żeby każdy użytkownik widział dane ze swojej własnej właściwości GA4. Przy dashboardzie dla jednej właściwości Service Account jest prostszy i bezpieczniejszy.

Ważna kwestia uprawnień: Service Account potrzebuje roli Viewer na poziomie właściwości GA4 — nie na poziomie konta Google Analytics ani projektu Google Cloud. Dodajesz go w GA4: Admin → Property Access Management. Rola Viewer wystarczy do wszystkich operacji odczytu. Nie nadawaj Editor ani Admin — zasada minimalnych uprawnień obowiązuje tak samo jak przy każdym innym kluczu API.

Utworzenie Service Account w Google Cloud

  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.

Environment variables dla GA4 Data API

Klucz JSON z Google Cloud zawiera kilkanaście pól, ale do autoryzacji potrzebujesz tylko dwóch: client_email i private_key. Reszta (project_id, token_uri itp.) jest obsługiwana przez bibliotekę automatycznie.

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"

Zwróć uwagę na private_key — w pliku JSON Google zapisuje znaki nowej linii jako \n (literalnie backslash-n). W zmiennej środowiskowej też je tak trzymamy, a w kodzie zamieniamy na prawdziwe znaki nowej linii przez .replace(/\\n/g, '\n'). Bez tej zamiany autoryzacja się wysypie z enigmatycznym błędem invalid_grant.

Instalacja klienta GA4 Data API

Code
npm install @google-analytics/data

Krok 2: GA4 Client po stronie serwera

BetaAnalyticsDataClient to główna klasa z pakietu @google-analytics/data i pomimo nazwy z "Beta" jest to stabilna, produkcyjna wersja klienta - Google po prostu nie zmienił nazwy po wyjściu z bety. Tworzymy go jako singleton, żeby nie inicjalizować połączenia i autoryzacji przy każdym requescie — to ważne szczególnie w Server Components, które mogą być wywoływane wielokrotnie w trakcie jednego page load.

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 z GA4

GA4 Data API operuje na trzech pojęciach: dimensions ( — co segmentujesz, np. pagePath, sessionSource, date), metrics ( — co mierzysz, np. totalUsers, sessions, bounceRate) i dateRanges (zakres dat). Każde zapytanie to kombinacja tych trzech elementów.

Ważna gotcha: nie wszystkie kombinacje wymiarów i metryk są kompatybilne. GA4 zwróci błąd INCOMPATIBLE_DIMENSIONS_METRICS, jeśli zestawisz wymiary i metryki, których Google nie może obliczyć razem. Przed budową produkcyjnych zapytań warto je przetestować w GA4 Dimensions & Metrics Explorer.

Daty w GA4 Data API mają dwa formaty. Możesz podawać "30daysAgo", "7daysAgo", "yesterday", "today" — wygodne dla dashboardów z presetami — albo konkretne daty "2026-01-01" w formacie YYYY-MM-DD. W odpowiedzi GA4 zwraca daty jako "20260101" (bez separatorów) — stąd helper formatGA4Date poniżej.

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 dla dashboardu GA4

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.

Jak działają limity GA4 Data API? Google używa modelu tokenowego: każde zapytanie kosztuje określoną liczbę tokenów z puli przypisanej do właściwości. Prosta kwerenda kosztuje mniej niż złożona z wieloma wymiarami, dużym limit i długim zakresem dat. Pula odnawia się w ciągu doby, ale jeśli ją wyczerpiesz (np. dashboard bez cache'u odwiedzony przez wielu użytkowników jednocześnie), API zwraca 429 Resource Exhausted. Bez cache'u jeden popularny dashboard może wyczerpać dzienny limit właściwości w ciągu minut.

vs z revalidate — to dwie różne strategie cachowania, które warto rozumieć:

Route Handler (/api/analytics/...) ma sens, gdy chcesz dynamicznie zmieniać zakres dat po stronie klienta (Date Range Picker) i pobierać dane bez przeładowania strony. Cache ustawiasz przez nagłówki HTTP.

Server Component z export const revalidate = 300 jest prostszy, gdy zakres dat jest stały lub ustawiany przez URL params. Next.js sam zarządza cachowaniem — dane są pobierane raz przy pierwszym requescie i odświeżane co 5 minut w tle przez .

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 dashboardu i wizualizacja w Recharts

Recharts to biblioteka oparta na i D3, zbudowana jako natywne komponenty React. Jej największa zaleta w kontekście Next.js to, że działa wyłącznie po stronie klienta — i to jest jednocześnie jedyne ograniczenie, o którym warto pamiętać.

W App Router oznacza to: wykresy muszą żyć w Client Components ("use client"). Dane pobierasz server-side (Server Component lub API Route), a do komponentu przekazujesz je przez props jako zwykłe tablice obiektów. To podział, który w przykładzie poniżej realizuje para DashboardPage (server) + DashboardClient (client).

ResponsiveContainer opakowuje każdy wykres i sprawia, że dopasowuje się do szerokości kontenera — bez tego Recharts renderuje wykres o sztywnych wymiarach, który się nie skaluje na ekranach mobilnych.

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 w dashboardzie

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. Granica Suspense izoluje dynamiczną część (Date Range Picker czytający URL) od reszty strony, która może pozostać statyczna i być serwowana z cache.

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 GA4: porównanie okresów

Porównanie z poprzednim okresem to jedna z najczęstszych potrzeb na dashboardach klienckich. Chodzi o znalezienie prostej odpowiedzi: "czy to lepiej czy gorzej niż miesiąc temu?". Da się to zrealizować dwoma osobnymi requestami, ale GA4 Data API obsługuje tablicę dateRanges z dwoma zakresami w jednym zapytaniu - co jest bardziej optymalne. Jest i tańsze tokenowo, i wygodniejsze w implementacji.

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.

Monitorowanie limitów GA4 Data API przez PropertyQuota

Każda odpowiedź z GA4 Data API zawiera pole propertyQuota z informacją o bieżącym zużyciu tokenów. Większość tutoriali to pomija — a to jeden z bardziej praktycznych mechanizmów, jeśli budujesz dashboard produkcyjny.

Code
// lib/ga4/quota.ts
import { getGA4Client, GA4_PROPERTY_ID } from './client'
 
export interface QuotaStatus {
  tokensPerDay: { consumed: number; remaining: number }
  tokensPerHour: { consumed: number; remaining: number }
  concurrentRequests: { consumed: number; remaining: number }
}
 
export async function getQuotaStatus(): Promise<QuotaStatus | null> {
  const client = getGA4Client()
 
  const [response] = await client.runReport({
    property: GA4_PROPERTY_ID,
    dateRanges: [{ startDate: 'today', endDate: 'today' }],
    metrics: [{ name: 'totalUsers' }],
    returnPropertyQuota: true,
  })
 
  const quota = response.propertyQuota
  if (!quota) return null
 
  return {
    tokensPerDay: {
      consumed: quota.tokensPerDay?.consumed ?? 0,
      remaining: quota.tokensPerDay?.remaining ?? 0,
    },
    tokensPerHour: {
      consumed: quota.tokensPerHour?.consumed ?? 0,
      remaining: quota.tokensPerHour?.remaining ?? 0,
    },
    concurrentRequests: {
      consumed: quota.concurrentRequests?.consumed ?? 0,
      remaining: quota.concurrentRequests?.remaining ?? 0,
    },
  }
}

Pole returnPropertyQuota: true w każdym zapytaniu daje aktualny stan limitów po wykonaniu tej kwerendy. Możesz logować te wartości (np. do konsoli serwera albo własnego systemu monitoringu) i reagować zanim GA4 zwróci 429. Przykładowo: jeśli tokensPerDay.remaining spada poniżej 20%, możesz zwiększyć TTL cache'a dynamicznie albo wysłać alerta.

Graceful degradation, gdy GA4 jest niedostępny

GA4 Data API zdarza się być chwilowo niedostępne lub wolne. Bez obsługi błędów cały dashboard pada z 500. Dobra architektura zakłada, że dane analityczne to funkcja wspierająca, nie krytyczna — i odpowiednio obsługuje awarię.

Code
// lib/ga4/safe-queries.ts
import { getOverview, getDailyMetrics, getTopPages } from './queries'
import type { AnalyticsOverview, DailyMetric, TopPage } from './queries'
 
const OVERVIEW_FALLBACK: AnalyticsOverview = {
  totalUsers: 0,
  newUsers: 0,
  sessions: 0,
  pageViews: 0,
  avgSessionDuration: 0,
  bounceRate: 0,
}
 
export async function safeGetOverview(
  startDate: string,
  endDate: string,
): Promise<{ data: AnalyticsOverview; error: boolean }> {
  try {
    const data = await getOverview(startDate, endDate)
    return { data, error: false }
  } catch (err) {
    console.error('[GA4] getOverview failed:', err)
    return { data: OVERVIEW_FALLBACK, error: true }
  }
}
 
export async function safeGetDailyMetrics(
  startDate: string,
  endDate: string,
): Promise<{ data: DailyMetric[]; error: boolean }> {
  try {
    const data = await getDailyMetrics(startDate, endDate)
    return { data, error: false }
  } catch (err) {
    console.error('[GA4] getDailyMetrics failed:', err)
    return { data: [], error: true }
  }
}
 
export async function safeGetTopPages(
  startDate: string,
  endDate: string,
): Promise<{ data: TopPage[]; error: boolean }> {
  try {
    const data = await getTopPages(startDate, endDate)
    return { data, error: false }
  } catch (err) {
    console.error('[GA4] getTopPages failed:', err)
    return { data: [], error: true }
  }
}

Wzorzec jest prosty, ponieważ każda funkcja zwraca { data, error } zamiast rzucać wyjątek. W Server Component można wtedy pokazać z informacją o niedostępności danych zamiast białej strony:

Code
// app/dashboard/page.tsx
export default async function DashboardPage() {
  const [overview, dailyMetrics, topPages] = await Promise.all([
    safeGetOverview('30daysAgo', 'today'),
    safeGetDailyMetrics('30daysAgo', 'today'),
    safeGetTopPages('30daysAgo', 'today'),
  ])
 
  const hasError = overview.error || dailyMetrics.error || topPages.error
 
  return (
    <>
      {hasError && (
        <div className="rounded border border-yellow-200 bg-yellow-50 px-4 py-3 text-sm text-yellow-800">
          Dane analityczne są chwilowo niedostępne. Spróbuj ponownie za kilka
          minut.
        </div>
      )}
      <DashboardClient
        overview={overview.data}
        dailyMetrics={dailyMetrics.data}
        topPages={topPages.data}
      />
    </>
  )
}

Promise.all z safe* funkcjami zapewnia, że błąd jednego endpointa GA4 nie blokuje renderowania pozostałych części dashboardu. Użytkownik widzi to, co jest dostępne, z jasnym komunikatem o problemie.

Bezpieczeństwo i rate limits w GA4 Data API

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. Cache jest częścią poprawnej architektury — bez niego dashboard z kilkoma widżetami i kilkudziesięcioma równoczesnymi użytkownikami wyczerpie dobowy limit szybciej, niż się spodziewasz.

Autoryzacja dashboardu: Zabezpiecz /dashboard lub server-side auth check. Dane z GA4 — nawet agregowane — mogą ujawniać wrażliwe informacje o ruchu i przychodach.

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 /analytics-dashboard/*.
// Bez tego konfiguratora Next.js odpala middleware na każdym żądaniu
// (statyki, fonty, API), co jest zbędnym kosztem.
export const config = {
  matcher: ['/analytics-dashboard/:path*'],
}
Kampanie, landing page, tracking konwersji, GA4 i GTM w jednym procesie.
Google Ads i Analityka

Często zadawane pytania

Czy GA4 Data API nadaje się do danych realtime?

GA4 Data API najlepiej nadaje się do raportów, agregatów i trendów. Do realtime lub surowego event streamu zwykle potrzebne są inne mechanizmy.

Gdzie trzymać klucze Service Account w Next.js?

Klucze powinny być używane wyłącznie po stronie serwera, w zmiennych środowiskowych lub bezpiecznym sekrecie. Nie wolno wysyłać ich do klienta.

Dlaczego cache jest ważny przy GA4 Data API?

API ma limity i koszt zapytań tokenowych. Cache ogranicza liczbę requestów, przyspiesza dashboard i stabilizuje działanie przy większej liczbie użytkowników.

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.

O autorze

Maciej Sala

Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwijam własne projekty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
Google Analytics 4 w Next.js App Router — konfiguracja z gtag i @next/third-parties

Integracja Google Analytics 4 w Next.js App Router to coś więcej niż wklejenie snippetu w , ponieważ nawigacja client-side, React Server Components, Consent Mode v2 i wybór między ręcznym gtag.js , a @next/third-parties sprawiają, że łatwo wprowadzić analitykę pozornie poprawną, ale w praktyce generującą zdublowane page views albo niepełne dane - nawiasem mówiąc, obie te sytuacje zdarzyły mi się w przeszłości. Ten artykuł łączy setup GA4 z realnymi niuansami App Router i podstawami planowania zdarzeń.

Maciej Sala

Maciej Sala

Founder StriveLab

Google Ads Remarketing w React – dynamiczne listy odbiorców i personalizacja reklam

Jak wdrożyć remarketing Google Ads w aplikacji React? Dynamiczny remarketing ecommerce, listy odbiorców na podstawie zachowań użytkowników i integracja z listami odbiorców w GA4.

Maciej Sala

Maciej Sala

Founder StriveLab

Google Ads Conversion Tracking w Next.js

Kompletny przewodnik po implementacji śledzenia konwersji Google Ads w Next.js. Od podstawowego gtag, przez enhanced conversions, po server-side tracking z Google Ads API.

Maciej Sala

Maciej Sala

Founder StriveLab