docs
    site-kit: Analytics
    v7.2.0.md

    Analytics — @sonordev/site-kit/analytics

    Automatic page view tracking, custom events, conversions, scroll depth, heatmap clicks, and Core Web Vitals. All data flows through the Sonor API.

    Usage

    SiteKitLayout mounts analytics for you. It renders {children} first and mounts AnalyticsProvider after them as a childless sibling, deferred until window load + idle (or the first interaction). Analytics never sits in the page's hydration path and can't push a route to client rendering. Configure it on the layout:

    // app/layout.tsx (server component)
    import { SiteKitLayout } from '@sonordev/site-kit/layout'
    import { ContactTracking } from '@sonordev/site-kit/analytics'
    
    export default function RootLayout({ children }) {
      return (
        <html lang="en">
          <body>
            <SiteKitLayout analytics={{ excludePaths: ['/admin'] }}>{children}</SiteKitLayout>
            {/* Optional: tel:/mailto: clicks as conversions. Childless, renders null. */}
            <ContactTracking />
          </body>
        </html>
      )
    }

    Don't wrap {children} in AnalyticsProvider yourself. It puts analytics in the hydration path (the page-view code ships in the page's initial scripts), and if it's lazy-loaded with next/dynamic({ ssr: false }) the whole route de-opts to client rendering.

    Without SiteKitLayout

    A layout that doesn't use core SiteKitLayout mounts analytics itself, and still as a deferred, childless island. Publish the credential server-side first with SiteKitCredential (see @sonordev/site-kit/client). Never pass SONOR_API_KEY to a client component.

    // app/analytics-shell.tsx
    'use client'
    import { AnalyticsProvider, ContactTracking } from '@sonordev/site-kit/analytics'
    
    export default function AnalyticsShell() {
      return (
        <AnalyticsProvider trackPageViews trackWebVitals>
          <ContactTracking />
        </AnalyticsProvider>
      )
    }
    // app/deferred-analytics.tsx
    'use client'
    import { lazy, Suspense } from 'react'
    import { useDeferredActivation } from '@sonordev/site-kit/client'
    
    const AnalyticsShell = lazy(() => import('./analytics-shell'))
    
    export function DeferredAnalytics() {
      const ready = useDeferredActivation(true)
      if (!ready) return null
      return (
        <Suspense fallback={null}>
          <AnalyticsShell />
        </Suspense>
      )
    }
    // app/layout.tsx (server component): {children} is a SIBLING of the island, never inside it
    import { resolveClientCredential } from '@sonordev/site-kit/server'
    import { SiteKitCredential } from '@sonordev/site-kit/client'
    import { DeferredAnalytics } from './deferred-analytics'
    
    export default async function RootLayout({ children }) {
      const credential = await resolveClientCredential(process.env.SONOR_API_KEY ?? '', 'https://api.sonor.io')
      return (
        <html lang="en">
          <body>
            <SiteKitCredential apiKey={credential} />
            {children}
            <DeferredAnalytics />
          </body>
        </html>
      )
    }

    AnalyticsProvider already mounts WebVitals when trackWebVitals is on. Don't add a second <WebVitals />, or every metric reports twice.

    Tracking custom events

    Under SiteKitLayout the provider is childless, so no page component sits inside it. Use the standalone functions. They need no provider and queue until the deferred provider mounts:

    'use client'
    import { trackEvent, trackConversion } from '@sonordev/site-kit/analytics'
    
    trackEvent({ name: 'button_click', category: 'engagement', properties: { buttonId: 'cta' } })
    trackConversion({ type: 'purchase', value: 99.99, currency: 'USD' })

    Hooks

    useAnalytics() and useTrackEvent() read the provider's context, so they only work in components rendered inside an AnalyticsProvider (children of your own analytics island, for example). Anywhere else they throw. Reach for the standalone functions above instead of wrapping {children} to make a hook work. useAnalyticsOptional() returns null instead of throwing.

    useAnalytics()

    const { trackEvent, trackConversion, sessionId, visitorId } = useAnalytics()

    useTrackEvent()

    const { trackEvent, trackConversion } = useTrackEvent()

    useContactTracking()

    Auto-tracks tel: and mailto: link clicks as conversions. Works anywhere: it uses the standalone dispatch, not the provider's context.

    const { trackPhoneClick, trackEmailClick } = useContactTracking()

    Components

    ComponentPurpose
    AnalyticsProviderThe tracker. SiteKitLayout mounts it childless; never wrap {children} in it
    WebVitalsAuto-reports LCP, CLS, TTFB, INP, FCP. AnalyticsProvider mounts it when trackWebVitals is on
    ContactTrackingAuto-tracks phone/email link clicks. Childless, renders null

    AnalyticsProvider Props

    interface AnalyticsConfig {
      projectId?: string           // Auto-resolved from API key
      trackPageViews?: boolean     // Default: true
      trackWebVitals?: boolean     // Default: true
      trackScrollDepth?: boolean   // Default: true
      sessionTimeout?: number      // Minutes (default: 30)
      excludePaths?: string[]      // Don't track these paths
      allowInFrame?: boolean       // Default: false — see below
      allowLocalhost?: boolean     // Default: false — local builds report nothing
      debug?: boolean              // Log events to console
    }

    What Gets Tracked Automatically

    • Page views — on every route change (path, URL, title, referrer, UTM params, device/browser/OS)
    • Web Vitals — LCP, CLS, TTFB, INP, FCP with good/needs-improvement/poor ratings
    • Contact clicks — tel: and mailto: links tracked as conversions, once <ContactTracking /> is mounted (it isn't by default)
    • DOM metadata — full snapshot per page view (meta tags, H1, word count, links, content, FAQs)

    Embedded pages report nothing (analytics.allowInFrame)

    When this site is loaded inside a cross-origin iframe, nothing is sent — no page views, journey/session rows, scroll depth, heatmap clicks, web vitals, events or conversions. The visitor is on whoever framed the page, not on this site, so every metric the frame produces is phantom traffic in the analytics its owner reads.

    A portfolio page that shows a live site in desktop, tablet and mobile frames is the common case, and securityHeaders' DEFAULT_FRAME_ANCESTORS permits it. Without the guard, one visit to that page would record three views of / on the framed site, milliseconds apart, sharing one session and visitor id, with the portfolio as the referrer. A screenshot laid over the frames hides the pixels, not the JavaScript.

    Same-origin frames still report. A site embedding itself (a preview pane, a print view, an on-domain booking frame) has a real visitor really on that site and no other tenant to pollute.

    Opt back in only when the frame IS the product — a widget or partner-hosted page deliberately distributed as an embed:

    <SiteKitLayout analytics={{ allowInFrame: true }}>…</SiteKitLayout>

    There is deliberately no env var or window global for this. A silent switch that turns cross-tenant tracking back on is the failure mode, not the feature.

    The decision lives in one place — shared/reporting-gate.ts, over the frame primitive in shared/frame.ts. Every send in the module routes through it, and send-gate.test.ts fails the build if a new one does not. isCrossOriginFrame() is exported from @sonordev/site-kit/analytics if a site needs the same answer for its own third-party pixels.

    Local builds report nothing (analytics.allowLocalhost)

    The actual browser hostname controls this gate, even in a production build with analytics.site or NEXT_PUBLIC_SITE_URL set to a production domain. localhost, its subdomains, 127.0.0.1, [::1], and 0.0.0.0 report nothing by default: page views, events, conversions, sessions, vitals, scroll and heatmap data, fleet heartbeats, and client-side sitemap registrations all use resolveAnalyticsTarget. Website chat and popups don't mount at all. Public hosts and same-origin frames on public hosts still report normally.

    For intentional local reporting:

    <SiteKitLayout analytics={{ allowLocalhost: true }}>…</SiteKitLayout>

    The layout passes this option to AnalyticsProvider, WebVitals, FleetHeartbeat, SitemapSync, SiteChat and SitePopups. Standalone components and sendFleetHeartbeat accept it too; standalone trackEvent/trackConversion use their mounted AnalyticsProvider's options. A local cross-origin frame needs both opt-ins. Neither option bypasses authentication. No environment variable or global silently enables local reporting.

    This is a browser gate: build-time createSitemap and Node fleet reporting still run. It doesn't disable forms, commerce, or Signal. When verifying those modules locally, point them at a test API as well.

    Environment

    Uses SONOR_API_KEY (injected by SiteKitLayout) or window.__SITE_KIT_API_KEY__.

    Multi-site projects (analytics.site)

    A single Sonor project can host many sub-sites (e.g. one project hosting example.com + a dozen regional microsites). Every analytics event is tagged with the sub-site host so the dashboard can roll up + filter per site without forcing each microsite into its own Sonor project.

    SiteKitLayout resolves the host in this precedence order:

    1. Explicit analytics.site config
    2. NEXT_PUBLIC_SITE_URL host
    3. window.location.host
    <SiteKitLayout
      analytics={{ site: 'ohio.example.com' }}
    >
      …
    </SiteKitLayout>

    Most projects leave it implicit — NEXT_PUBLIC_SITE_URL is already set per microsite, so the dimension fills in automatically. The dashboard shows a "Site" picker + a "Sites" tab as soon as ≥2 distinct hosts are detected.