# React analytics with juuna

Last updated · 2026-08-31

**TL;DR**: to add analytics to a React single-page app built with Vite, install
`@muziris/juuna`, call `juuna.init` once in `main.tsx`, and record a pageview
with `juuna.page()` on every route change. Events appear 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/react/llms.txt](https://juuna.app/docs/react/llms.txt) for AI agents.

## Install

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

## Initialise once in main.tsx

Initialise before the app renders. There is no provider or context to set up; the
exported `juuna` client is a module-level singleton you can import anywhere.

```tsx
// src/main.tsx
import { createRoot } from 'react-dom/client'
import { juuna } from '@muziris/juuna'
import App from './App'

juuna.init('wk_YOUR_WRITE_KEY', { apiHost: 'https://juuna.app' })

createRoot(document.getElementById('root')!).render(<App />)
```

If the app has no client-side router, record the one pageview right after `init`
with `juuna.page()` and you are done. Optional autocapture goes in the same call:
`juuna.init('wk_…', { apiHost: '…', webVitals: true, clickTracking: true, scrollTracking: true, sessionReplay: true })`.

## Track route changes with React Router

The SDK has no automatic SPA route detection and never patches the History API: each
pageview is an explicit `juuna.page()` call. With React Router, one null component
inside the router is enough:

```tsx
// src/pageviews.tsx
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { juuna } from '@muziris/juuna'

export function Pageviews() {
  const { pathname } = useLocation()
  useEffect(() => {
    juuna.page()
  }, [pathname])
  return null
}
```

```tsx
// src/App.tsx
<BrowserRouter>
  <Pageviews />
  {/* your routes */}
</BrowserRouter>
```

The effect runs on the first mount and again on every navigation, and `init`
records nothing on its own, so each view is counted exactly once, the first
included.

## The script tag alternative

For a React app whose bundle you would rather not touch, the hosted tag in
`index.html` works too:

```html
<!-- index.html, before </head> -->
<script defer src="https://juuna.app/sdk/juuna.js"
        data-write-key="wk_YOUR_WRITE_KEY"
        data-api-host="https://juuna.app"></script>
```

The tag initialises itself and records the initial pageview on its own, so a
route-change tracker used with it must skip its first run. With a client-side router,
prefer the npm package above; it keeps one code path and it is the only build that
supports session replay.

## 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.

### Do I need a provider or React context?

No. The exported juuna client is a module-level singleton; import it in any file once init has run. Calls made before init are dropped silently rather than thrown, so ordering mistakes degrade to missing events, never to a crash.

### Can a React SPA use session replay?

Yes. Pass sessionReplay: true to juuna.init when using the npm package. The recorder, rrweb, lazy-loads as its own chunk so the core bundle stays tiny, and recordings are masked by default. The script tag build excludes replay.
