StriveLab
Strony internetowe
Usługi
RealizacjeO mnieBlogPorozmawiajmy
PL
EN

Astro

Ultraszybkie projekty, łączące lekkość ze skalowalnością.

Next.js

Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.

React

Połączenie intuicyjności z wydajnością, które zapewnia bezproblemową skalowalność kodu.

SEO & Performance

Audyt techniczny i optymalizacja pod kątem SEO i GEO.

Automatyzacja AI

Bezpieczne automatyzacje procesów i agenci AI w n8n, Make i Claude.

QA & Automation

Testy automatyczne komponentów i E2E w Cypress.

Doradztwo produktowe

Połączenie perspektywy produktu, developera i marketingu w jednym miejscu

StriveLab
Strony internetowe
Usługi
RealizacjeO mnieBlogPorozmawiajmy
PL
EN

Astro

Ultraszybkie projekty, łączące lekkość ze skalowalnością.

Next.js

Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.

React

Połączenie intuicyjności z wydajnością, które zapewnia bezproblemową skalowalność kodu.

SEO & Performance

Audyt techniczny i optymalizacja pod kątem SEO i GEO.

Automatyzacja AI

Bezpieczne automatyzacje procesów i agenci AI w n8n, Make i Claude.

QA & Automation

Testy automatyczne komponentów i E2E w Cypress.

Doradztwo produktowe

Połączenie perspektywy produktu, developera i marketingu w jednym miejscu

Astro

Ultraszybkie projekty, łączące lekkość ze skalowalnością.

Next.js

Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.

React

Połączenie intuicyjności z wydajnością, które zapewnia bezproblemową skalowalność kodu.

SEO & Performance

Audyt techniczny i optymalizacja pod kątem SEO i GEO.

Automatyzacja AI

Bezpieczne automatyzacje procesów i agenci AI w n8n, Make i Claude.

QA & Automation

Testy automatyczne komponentów i E2E w Cypress.

Doradztwo produktowe

Połączenie perspektywy produktu, developera i marketingu w jednym miejscu

RealizacjeO mnieBlog
Porozmawiajmy
PL
EN

Nowoczesne strony internetowe dla firm, które myślą odważnie.

Przewiń do góry

Nazwa

StriveLab Maciej Sala

NIP

6772218995

REGON

524008527

E-mail

contact@strivelab.pl

Usługi główne
  • Tworzenie stron internetowych
  • Strony internetowe Next.js
  • Strony internetowe Astro
  • Strony internetowe React
Inne usługi
  • Usługi
  • SEO & Performance Sprint
  • QA & Stabilizacja
  • Konsultacje Product / Delivery
  • Automatyzacja Procesów AI
  • Aplikacje webowe Next.js
  • Współpraca ciągła
Strony
  • O mnie
  • Usługi
  • Realizacje
  • Blog

© 2026 StriveLab.pl

Polityka prywatności
MarketingNext.jsReact

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.

OpublikujLinkedInFacebookWyślij
Autor
Maciej Sala
Opublikowano
31 sierpnia 2025 11:30
Czytanie
8 min czytania
Aktualizacja
27 maja 2026 08:00

Interfejs GA4 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 i prezentuje je w czytelnej formie.

Artykuł w skrócie

  • Service Account zamiast OAuth — server-side GA4 Data API wymaga Service Account z uprawnieniem Viewer w GA4 Property
  • Cache od pierwszego dnia — owinięcie zapytań w unstable_cache lub Route Handler z revalidate chroni przed przekroczeniem limitów tokenowych API
  • keyEvents, nie conversions — nowe GA4 Data API używa keyEvents jako nazwy metryki; stara nazwa conversions jest przestarzała
  • Dane mają opóźnienie — GA4 Data API zwraca dane spóźnione o kilka minut do kilku godzin; do real-time używaj Realtime API
  • Recharts po stronie klienta — wykresy renderuj w Client Components; dane pobieraj server-side i przekazuj jako props
  • Porównanie okresów — GA4 Data API obsługuje dateRanges z dwoma zakresami w jednym requescie, bez potrzeby dwóch zapytań
Uwaga

Nie wystawiaj danych Service Account do przeglądarki. Dashboard GA4 powinien pobierać dane po stronie serwera, cache'ować odpowiedzi i zwracać frontendowi tylko gotowe agregaty potrzebne do widoku.

Kiedy warto budować custom dashboard?

GA4, czyli Google Analytics 4, to aktualna wersja platformy analitycznej Google do pomiaru zdarzeń i zachowań użytkowników. 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 CMS-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 – Service Account

GA4 Data API 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

  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

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

Code
npm install @google-analytics/data

Krok 2: GA4 Client – server-side

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

GA4 Data API operuje na trzech pojęciach: dimensions (Wymiar w GA4 to atrybut opisujący dane, np. pagePath, sessionSource, deviceCategory — odpowiada na pytanie 'czego dotyczy ten rekord'. — co segmentujesz, np. pagePath, sessionSource, date), metrics (Metryka w GA4 to wartość liczbowa, np. totalUsers, sessions, bounceRate — odpowiada na pytanie 'ile'. — 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

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.

Funkcja eksportowana z pliku route.ts w App Routerze, obsługująca metodę HTTP (GET, POST itd.). Przyjmuje standardowy Request i zwraca Response — to webowy odpowiednik dawnych API Routes. vs Server Component to komponent renderowany wyłącznie na serwerze. Nie trafia do przeglądarki jako kod JavaScript — wysyła gotowy HTML. Dlatego jego console.log pojawia się w terminalu serwera, a nie w konsoli przeglądarki, i nie widać go w React DevTools. 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 ISR, czyli Incremental Static Regeneration, pozwala odświeżać strony statyczne w tle bez pełnego rebuildu — strona jest serwowana z cache, a Next.js regeneruje ją po upływie czasu revalidate..

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

Recharts to biblioteka oparta na SVG, czyli Scalable Vector Graphics, to format grafiki wektorowej renderowanej przez przeglądarkę — skalowalny bez utraty jakości, dostępny w DOM. 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

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 – 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 – 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 – co 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ć UI 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

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 Middleware w Next.js to funkcja wykonywana przed obsługą żądania — pozwala np. sprawdzić sesję i przekierować na login zanim strona zostanie wyrenderowana. 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 /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*'],
}

Werdykt Labu

GA4 Data API w Next.js ma sens wtedy, gdy wiesz dokładnie, co chcesz pokazać i komu. Nie zastępuje GA4 jako narzędzia analitycznego — zastępuje potrzebę dawania klientowi dostępu do GA4.

O tym, czy dashboard będzie stabilny w produkcji, decydują dwie rzeczy, które większość tutoriali pomija: cache od pierwszego dnia — bez niego jeden popularny dashboard wyczerpie dzienny limit tokenów — i graceful degradation — bez niej chwilowa niedostępność GA4 API wywraca cały panel. Obie można dodać w godzinę, a zostawianie ich "na potem" to klasyczny błąd, który wraca z odsetkami.

Bezpieczeństwo — middleware z weryfikacją sesji na /dashboard — wdrażaj razem z pierwszą wersją, nie po.

  • Kiedy warto budować custom dashboard?1 min
  • Krok 1: Autoryzacja – Service Account2 min
  • Krok 2: GA4 Client – server-side1 min
  • Krok 3: Funkcje do pobierania danych1 min
  • Krok 4: API Routes z cache1 min
  • Krok 5: Frontend – wizualizacja z Recharts1 min
  • Krok 6: Date Range Picker1 min
  • Zaawansowane query – porównanie okresów1 min
  • Monitorowanie limitów – PropertyQuota1 min
  • Graceful degradation – co gdy GA4 jest niedostępny?1 min
  • Bezpieczeństwo i rate limits1 min
  • Werdykt Labu1 min

Często zadawane pytania

Źródła i dokumentacjaZweryfikowano: 19 maja 2026

Materiały wykorzystane do weryfikacji artykułu „GA4 Data API w Next.js – budujemy własny dashboard analityczny”:

Google Analytics Data API Overview, Create a report, API Dimensions & Metrics, Create a Realtime report, PropertyQuota, DateRange.

Seria

Analityka i kampanie w Next.js
Część 2 / 4
  1. 1Google Analytics 4 w Next.js App Router — konfiguracja z gtag i @next/third-parties
  2. GA4 Data API w Next.js – budujemy własny dashboard analityczny
  3. 3Google Ads Remarketing w React – dynamiczne listy odbiorców i personalizacja reklam
  4. 4Landing page dla Google Ads w Next.js — jak budować strony, które konwertują
Maciej Sala

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 rozwija własne projekty side-projecty.

Moje artykułyWięcej o mnie

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

Skontaktuj się ze mną

Biblioteka wiedzy

Czytaj dalej

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

Jak poprawnie wdrożyć GA4 w Next.js App Router: gtag, @next/third-parties, page_view przy client-side navigation, consent mode v2 i custom events bez chaosu w danych.

Maciej Sala

Maciej Sala

Founder Strivelab

18 października 2025
Google Ads Remarketing w React – dynamiczne listy odbiorców i personalizacja reklam
Google Ads Remarketing w React – dynamiczne listy odbiorców i personalizacja reklam

Remarketing Google Ads w React i Next.js bez marketingowych uproszczeń: eventy, Merchant Center, listy odbiorców w GA4, Customer Match i wymogi consent.

Maciej Sala

Maciej Sala

Founder Strivelab

6 grudnia 2025
Core Web Vitals — jak przyspieszyć stronę i poprawić pozycję w Google
Core Web Vitals — jak przyspieszyć stronę i poprawić pozycję w Google

Core Web Vitals to kluczowe metryki wydajności i doświadczenia użytkownika. Poznaj LCP, INP i CLS oraz zobacz, jak je mierzyć, monitorować i poprawiać w praktyce.

Maciej Sala

Maciej Sala

Founder Strivelab

14 listopada 2025
Poprzedni wpisClaude vs ChatGPT vs Gemini — porównanie dla deweloperówPraktyczne porównanie Claude, ChatGPT i Gemini z perspektywy dewelopera. Kodowanie, analiza, API, prywatność i workflow — kiedy które narzędzie ma sens.
Maciej Sala

Maciej Sala

Founder Strivelab

12 sierpnia 2025
Następny wpisGoogle Tag Manager w Next.js — dataLayer, custom triggers i debugowanie jak proJak wdrożyć Google Tag Manager w Next.js App Router bez chaosu w dataLayer: page_view, custom events, ecommerce, consent mode i debugowanie.
Maciej Sala

Maciej Sala

Founder Strivelab

25 września 2025