# SvelteKit analytics with juuna

Last updated · 2026-08-31

**TL;DR**: to add analytics to a SvelteKit app, install `@muziris/juuna`,
initialise it in the root `+layout.svelte` behind a `browser` guard, and record a
pageview from `afterNavigate`, which covers the first load and every client-side
navigation with one call.

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

## Install

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

## Initialise in the root layout

```svelte
<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import { browser } from '$app/environment'
  import { afterNavigate } from '$app/navigation'
  import { juuna } from '@muziris/juuna'

  let { children } = $props()

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

  afterNavigate(() => {
    juuna.page()
  })
</script>

{@render children()}
```

On Svelte 4, keep your existing `<slot />` in place of the `children` render; the
script block is the integration. Two details make it exact:

- `afterNavigate` runs after the layout first mounts and again after every
  client-side navigation, and `juuna.init` records nothing on its own, so each view
  is counted exactly once, the first included.
- The `browser` guard keeps `init` out of server-side rendering. Importing the
  package on the server is safe (it touches no browser global at import time), but
  the client should only ever start in the browser.

The root layout persists across navigations, so `init` runs once per visit. The SDK
has no automatic SPA route detection and never patches the History API; the one
`afterNavigate` call is the whole of route tracking. Optional autocapture goes in
the same call:
`juuna.init('wk_…', { apiHost: '…', webVitals: true, clickTracking: true, scrollTracking: true, sessionReplay: true })`.

## The script tag alternative

Add the hosted tag to `src/app.html` and skip the first `afterNavigate`, because
the tag records the initial pageview itself:

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

```svelte
<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import { afterNavigate } from '$app/navigation'

  afterNavigate((nav) => {
    if (nav.type === 'enter') return // initial load: the tag already recorded it
    ;(window as unknown as { juuna?: { page: () => void } }).juuna?.page()
  })
</script>
```

One limitation: session replay is not available from the script build. It needs the
npm package (`sessionReplay: true`).

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

### Is the package safe with server-side rendering?

Yes. Importing @muziris/juuna touches no browser global at import time, so SSR and prerendering never break. Keep init behind the browser guard so the client only ever starts in the browser.

### Do prerendered pages get counted?

Yes. A prerendered SvelteKit page still hydrates in the visitor browser, the layout script runs there, and afterNavigate fires. The exception is a route that switches client-side JavaScript off entirely with csr = false: no app code runs there, so use the script tag in app.html for those pages, which still ships in the HTML.

### Can a SvelteKit app use session replay?

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