GA4 page_view Tracking Reinforcement for All Pages (App Router Soft-Navigation)
SummaryDiagnosed that the GA4 integration from Sprint 210 — injected once into RootLayout — only fires a page_view on the initial load, and reinforced client-side navigation (soft navigation) page_view tracking with a code-based solution. The user selected Option B (code-based tracker) — sends sendGAEvent('page_view') on usePathname + useSearchParams changes without depending on Enhanced Measurement history events, fully testable. Key requirements: Suspense boundary mandatory for useSearchParams in App Router; initial mount skip prevents duplicate page_view from the gtag config call.
Goals
- Reinforce GA4 tracking so that page_view events are captured during App Router client-side navigation (soft navigation) as well, building on the Sprint 210 integration.
- Introduce a code-based route tracker that guarantees tracking independently of GA4 admin settings (Enhanced Measurement) and makes tracking testable.
- Prevent duplicate page_view events on the initial mount.
Background
In Sprint 210, the GoogleAnalytics component from @next/third-parties/google was inserted into RootLayout. This component makes a single gtag('config', gaId) call, so only the initial-load page_view is fired. Because the App Router does not perform a full page refresh during client-side route transitions (soft navigation), gtag config is not called again.
Current state: soft-navigation page_view events depend entirely on GA4 Enhanced Measurement's 'page changes based on browser history events' setting (ON by default).
Two options were considered.
- Option A: Rely on Enhanced Measurement (lightweight). When the GA4 admin setting is ON, GA4 automatically processes history-event-based page_view. No code changes needed.
- Option B: Code-based tracker. Detects
usePathname+useSearchParamschanges and callssendGAEvent('event', 'page_view', ...)directly. Independent of GA4 admin settings, fully testable. Recommends turning off Enhanced Measurement's history-based page changes to prevent double-counting.
The user selected Option B (code-based tracker).
Decisions
D0. Route Tracking Approach — Code-Based Tracker (Option B)
Option A depends on GA4 admin configuration state, which cannot be guaranteed in code and cannot be tested. Decision: proceed with Option B.
It is recommended that the user turn off 'page changes based on browser history events' in GA4 admin → Enhanced Measurement settings to prevent double-counting (user action required; outside code scope).
D1. Mandatory Suspense Boundary for useSearchParams
In Next.js App Router, client components using useSearchParams() must be wrapped in a <Suspense> boundary. Without it, a static-rendering deopt warning is emitted at build time, and potentially the entire route is downgraded from SSR to client-side rendering.
D2. Initial Mount Skip — Duplicate page_view Prevention
The parent GoogleAnalytics component's gtag('config', gaId) call already fires a page_view on the initial load. A useRef(true) flag detects the initial mount and skips the sendGAEvent call on the first render.
Implementation
Phase A — New GoogleAnalyticsRouteTracker
New src/components/analytics/GoogleAnalyticsRouteTracker.tsx ('use client'):
- Uses
usePathnameanduseSearchParams(next/navigation) to detect the current path and query parameters (including the full path with next-intl localePrefix 'as-needed') useRef(true)initial-mount flag — skips on first render (prevents duplicate with gtag config initial page_view)useEffect([pathname, searchParams])— on change, callssendGAEvent('event', 'page_view', { page_location: window.location.href, page_title: document.title })- Returns
null(no UI)
Phase B — GoogleAnalytics.tsx Update
Updated src/components/analytics/GoogleAnalytics.tsx:
- When
measurementIdis present, rendersNextGoogleAnalyticstogether with<Suspense fallback={null}><GoogleAnalyticsRouteTracker /></Suspense> GoogleAnalyticsRouteTrackerusesuseSearchParams, which requires a Suspense boundary in App Router- When the measurement ID is unset, returns
nullno-op (inheriting the Sprint 210 pattern)
Phase C — Tests
New src/components/analytics/GoogleAnalyticsRouteTracker.test.tsx — 7 cases:
- Confirms
sendGAEventis not called on the initial mount (skip) - Confirms
nullreturn - Confirms
sendGAEventis called onpathnamechange - Verifies
sendGAEventarguments — event name'event', action'page_view' - Confirms
sendGAEventis called onsearchParamschange page_locationstring type verification (jsdomwindow.location.hrefis non-configurable; type verification used instead of exact value assertion)page_title— precisedocument.titleverification
Augmented src/components/analytics/GoogleAnalytics.test.tsx — 2 additional cases:
- Confirms
GoogleAnalyticsRouteTrackerrenders alongside when measurement ID is set - Confirms
GoogleAnalyticsRouteTrackerdoes not render when measurement ID is unset
Phase D — No Dependency or CSP Changes
sendGAEvent is a wrapper around the existing dataLayer push, so no new external domains are introduced. No new npm packages, no npm install run, no package-lock.json changes (following the Sprint 210 lockfile-prune prevention pattern).
Verification
Oracle direct verification (npm ci-based CI environment reproduction):
npm ciEXIT=0 — monaco-editor and other dependencies preserved; no lockfile driftnpx tsc --noEmit→ EXIT=0, 0 errorsnpx next lint(raw) → EXIT=0, 0 warnings on new analytics files (only pre-existing chart/sidebar warnings)npx next build→ EXIT=0, ✓ Compiled 16.6s, static 5/5 (no Suspense deopt)test:coverage→ EXIT=0, 134 suites / 1390 tests (1381 existing + 9 new)GoogleAnalytics.tsx100% (stmts/branch/funcs/lines)GoogleAnalyticsRouteTracker.tsx100% (stmts/branch/funcs/lines)- Global thresholds met (lines 83 / branches 71 / functions 82 / statements 81)
Commit: 20a8eed feat(frontend): GA4 라우트 변경 page_view 추적 트래커 추가
ADR Index Gates
node scripts/check-adr-index-count.mjs --strict→ permanent 8 / topic 1 / sprint 149node scripts/check-adr-en-coverage.mjs --lint→ 158/158 (100%)node scripts/check-doc-refs.mjs→ 0 brokennode scripts/check-i18n-residue.mjs --strict→ prose Hangul max 2.19% (within 8% threshold)
New Patterns
- Explicit page_view tracker pattern for App Router route changes — Detects
usePathname+useSearchParamschanges + skips initial mount withuseRef+ callssendGAEvent('event', 'page_view', ...)directly. Independent of GA4 admin settings, fully testable, prevents double-counting. useSearchParamsSuspense isolation pattern — Wrap client components that depend onuseSearchParams()with<Suspense fallback={null}>at the parent level. Standard pattern for preventing static rendering deopt in App Router.
Critic Cross-Review
R1 — CLEAN (Codex gpt-5.5, codex review --base bbfbc2a, session 019e6dbd-50ba-7d40-ba5f-fee30c9c7845)
"No discrete correctness issues were identified in the GA route-tracking changes or accompanying tests/docs. The implementation is consistent with the stated App Router soft-navigation tracking approach."
Critical / High / Medium / Low findings all 0. No regression found across the route tracker, tests, or ADR changes. R1 CLEAN, so no additional rounds are required (per the Critic placeholder regression-prevention decision — only R1 is persisted).