Next.js analytics with juuna
Last updated · 2026-08-31
TL;DR: to add analytics to a Next.js App Router app, install @muziris/juuna, mount one small client component that calls juuna.init once and juuna.page() on every route change, and events show up in your juuna dashboard within seconds.
juuna is product analytics on a dedicated instance of your own: traffic, funnels, heatmaps, session replay and Web Vitals from one small, dependency-free SDK. Every event belongs to a source identified by a public write key (wk_…), created in the dashboard under Settings → Sources; use yours in place of wk_YOUR_WRITE_KEY below. The full integration guide covers the SDK reference, autocapture and the HTTP API, and this page is served as plain markdown at https://juuna.app/docs/nextjs/llms.txt for AI agents.
Install the SDK
npm install @muziris/juuna # or pnpm add / yarn addCreate one client component. It initialises juuna exactly once and records a pageview for the current route and for every client-side navigation after it:
// app/juuna.tsx
'use client'
import { usePathname } from 'next/navigation'
import { useEffect } from 'react'
import { juuna } from '@muziris/juuna'
let started = false
export function Juuna() {
const pathname = usePathname()
useEffect(() => {
if (!started) {
started = true
juuna.init('wk_YOUR_WRITE_KEY', { apiHost: 'https://juuna.app' })
}
juuna.page()
}, [pathname])
return null
}Mount it once in the root layout, below the app:
// app/layout.tsx
import { Juuna } from './juuna'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Juuna />
</body>
</html>
)
}That is the whole integration. usePathname is the App Router's navigation signal, so the effect re-runs on every client-side route change; the module-level started guard keeps init to exactly once across those re-runs. Optional autocapture goes in the same call: juuna.init('wk_…', { apiHost: '…', webVitals: true, clickTracking: true, scrollTracking: true, sessionReplay: true }).
How route tracking works
The SDK never patches the History API and has no automatic SPA route detection: every pageview is an explicit juuna.page() call. In the App Router that is the usePathname effect above, and nothing else fires. One visible call site is the whole of route tracking, which is deliberate; there are no framework-version-sensitive history hooks to break.
The script tag alternative
To keep the SDK out of your bundle entirely, load the hosted tag and track route changes with a small client component. The tag initialises itself from its data-* attributes and records the initial pageview on its own, so the tracker skips the first route. This is exactly how juuna.app instruments its own Next.js pages.
// app/layout.tsx: import { RouteTracker } from './route-tracker'
// then, inside <body>, below {children}:
<script
defer
src="https://juuna.app/sdk/juuna.js"
data-write-key="wk_YOUR_WRITE_KEY"
data-api-host="https://juuna.app"
/>
<RouteTracker />// app/route-tracker.tsx
'use client'
import { usePathname } from 'next/navigation'
import { useEffect } from 'react'
let lastTracked: string | null = null
export function RouteTracker() {
const pathname = usePathname()
useEffect(() => {
if (lastTracked === null) {
lastTracked = pathname // initial load: the tag already recorded it
return
}
if (lastTracked === pathname) return
lastTracked = pathname
;(window as unknown as { juuna?: { page: () => void } }).juuna?.page()
}, [pathname])
return null
}One limitation: session replay is not available from the script build. It needs the npm package (sessionReplay: true).
Server-side events
Importing @muziris/juuna is safe in code that also runs on the server (it touches no browser global at import time), but the client belongs in the browser. To record events from route handlers, server actions or background jobs, POST them to the HTTP API instead: POST https://juuna.app/api/v1/batch with Authorization: Bearer wk_…. The HTTP API reference has the full contract.
Custom events and identity
The same client that records pageviews takes named events and identity. Call it from the package import, or from window.juuna when using the script tag:
juuna.track('Signup Completed', { plan: 'pro' })
juuna.identify('user_42', { email: 'ada@example.com' }) // after sign-in
juuna.reset() // on logoutPageviews, sessions, visitors, referrers, devices, browsers and geography are all recorded without any of this. Add a custom event only where a real product question needs one; identify stitches a visitor's anonymous history to their user id, including activity from before they signed in.
Verify it works
Click through a few pages in a normal browser window. Automated browsers are deliberately ignored (the SDK records nothing when navigator.webdriver is set), so a Playwright or Selenium session will never show up. Then check either end of the wire:
- Network tab: filter for
/api/v1/batch. Events batch in memory and flush every 5 seconds by default, at 20 queued events, or when the tab is hidden, so allow a few seconds. A delivered batch answers{"accepted":1,"rejected":0}with a 200. - Dashboard: the source's overview shows live visitors and new events within seconds of a flush.
A 401 means the write key is wrong or unknown. A 403 means the source has allowed domains configured and the origin you are testing from is not among them. If nothing is sent at all, check that localStorage.juuna_ignore is not 'true' in that browser: it is the per-browser opt-out.
FAQ
Why do I see two pageviews per route in development?
React Strict Mode mounts effects twice in development, so the pageview effect fires twice there. Production builds mount effects once and record one pageview per route, so the numbers on a deployed site are correct.
Does this work with the Pages Router?
Yes. Call juuna.init once in _app, record one pageview for the initial load, and record the rest from the router routeChangeComplete event, which fires on every client-side navigation but not on the first load. The SDK is the same; only the navigation signal differs.
Do I need next/script for the script tag?
No. The tag is 1.0 KB gzipped, loads with defer, and reads its configuration from its own data attributes, so a plain script element in the root layout is all it takes. That is how juuna.app loads it on its own pages.