# 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](https://juuna.app/docs) 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](https://juuna.app/docs/nextjs/llms.txt) for AI agents.

## Install the SDK

```bash
npm install @muziris/juuna     # or pnpm add / yarn add
```

Create one client component. It initialises juuna exactly once and records a pageview
for the current route and for every client-side navigation after it:

```tsx
// 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:

```tsx
// 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.

```tsx
// 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 />
```

```tsx
// 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](https://juuna.app/docs#http-api) 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:

```ts
juuna.track('Signup Completed', { plan: 'pro' })
juuna.identify('user_42', { email: 'ada@example.com' }) // after sign-in
juuna.reset()                                           // on logout
```

Pageviews, 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.
