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
| Component | Purpose |
|---|---|
AnalyticsProvider | The tracker. SiteKitLayout mounts it childless; never wrap {children} in it |
WebVitals | Auto-reports LCP, CLS, TTFB, INP, FCP. AnalyticsProvider mounts it when trackWebVitals is on |
ContactTracking | Auto-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:andmailto: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:
- Explicit
analytics.siteconfig NEXT_PUBLIC_SITE_URLhostwindow.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.