npm package: @pakicetus97/next-analytics
Description: App Router-only analytics provider for Next.js that posts events to a configurable collect endpoint.
Latest version: 0.2.2

# @pakicetus97/next-analytics

Most analytics solutions are either too heavy, too expensive, or require you to hand your data to a third party. This package takes a different approach: it is a thin client-side layer you drop into your Next.js app that collects a handful of meaningful signals — page views, scroll depth, web vitals, and anchor navigation — and sends them to an endpoint you own and control. No external dashboard, no tracking script from a CDN, no vendor lock-in.

The idea is to keep things simple. You configure the `<AnalyticsProvider>` once in your root layout, point it at a route in your own project, and you are done. The package intentionally stays on the coarse side: it tells you which pages were visited, on what kind of device, roughly how far users scrolled, and how fast the page felt. It does not track mouse movements, clicks on individual elements, form inputs, or anything else that would require a more invasive approach.

If you want a quick, self-hosted overview of how your app is being used without setting up a full analytics platform, this package is for you. The scope is deliberately narrow for now, but the package will be extended as new use cases come up.

---

App Router-only analytics package for Next.js. It sends client-side analytics
events to a configurable collect endpoint — all it needs is something that
accepts a `POST` request with a JSON body. That endpoint can live inside the
same consumer project (e.g. `app/api/collect/route.ts`) or in a completely
separate, centralised analytics API that receives events from multiple websites
at once. Either way, the package does not care: you own the infrastructure,
you decide where the data lands.

Because the endpoint is called directly from the browser it is publicly
reachable by definition. If you expose it on the open internet, make sure to
follow the [security recommendations](#security-recommendations) in the
*Implementing the Collect Endpoint* section below.

## Installation

```bash
pnpm add @pakicetus97/next-analytics
```

## Usage

```tsx
import { AnalyticsProvider } from '@pakicetus97/next-analytics'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="de">
      <body>
        <AnalyticsProvider endpoint="/api/collect" />
        {children}
      </body>
    </html>
  )
}
```

The `endpoint` can be a relative path inside the consumer app or a full URL:

```tsx
<AnalyticsProvider endpoint="https://analytics.example.com/collect" />
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `endpoint` | `string` | — | URL to POST events to |
| `publicKey` | `string` | — | Sent as `X-Public-Key` header on every request |
| `sessionStorageKey` | `string` | `"analytics_session_id"` | Key used to persist the session ID |
| `batch` | `boolean \| BatchOptions` | — | Queue events and flush in batches |
| `retry` | `boolean \| RetryOptions` | — | Retry failed requests with exponential backoff |
| `throttle` | `boolean \| ThrottleOptions` | — | Limit how often requests are sent |

### `batch`

```tsx
// defaults: maxSize 10, flushIntervalMs 5000
<AnalyticsProvider endpoint="/api/collect" batch />

// custom
<AnalyticsProvider endpoint="/api/collect" batch={{ maxSize: 20, flushIntervalMs: 10000 }} />
```

Events are collected in a queue and sent together when either `maxSize` is
reached or `flushIntervalMs` has elapsed. The queue is always force-flushed on
page unload, route change, and when the tab becomes hidden.

### `retry`

```tsx
// defaults: attempts 3, delayMs 500 (exponential: 500ms → 1000ms → 2000ms)
<AnalyticsProvider endpoint="/api/collect" retry />

// custom
<AnalyticsProvider endpoint="/api/collect" retry={{ attempts: 5, delayMs: 300 }} />
```

Only retries on network errors, not on HTTP error status codes. Retry is
skipped for requests that are sent on page unload (no time to wait).

### `throttle`

```tsx
// default: minIntervalMs 1000
<AnalyticsProvider endpoint="/api/collect" throttle />

// custom
<AnalyticsProvider endpoint="/api/collect" throttle={{ minIntervalMs: 2000 }} />
```

Drops events that arrive within `minIntervalMs` of the last send. When `batch`
is enabled, `throttle` is ignored — the flush interval already controls request
rate.

### Combined example

```tsx
<AnalyticsProvider
  endpoint="/api/collect"
  publicKey="pk_live_abc123"
  batch={{ flushIntervalMs: 3000 }}
  retry
/>
```

## Implementing the Collect Endpoint

This package does **not** include a `/api/collect` handler. You implement it yourself in your consumer project. Here is everything you need to know.

### HTTP Request

```
POST {endpoint}
Content-Type: application/json
X-Public-Key: <your-key>   // only present when publicKey prop is set
```

The body is either a **single event object** or an **array of event objects** (when `batch` is enabled). Your handler must accept both.

The endpoint can live inside the same Next.js project (e.g. `app/api/collect/route.ts`) or be a completely separate service — as long as it accepts `POST` requests with a JSON body.

The client ignores the response body — returning `200` or `204` is enough. Error status codes are **not** retried automatically (only network-level errors are, when `retry` is configured).

#### Security recommendations

Because the collect endpoint is called directly from the browser, it is publicly reachable by definition — anyone can POST to it. The following measures help you keep the noise out and make sure only your own app can write meaningful data into your backend.

- **Validate the `X-Public-Key` header** if you use the `publicKey` prop. Reject requests with a missing or invalid key with `401`. Enforce a strict format on the key itself (e.g. a fixed prefix + hex string of a known length) so that arbitrary strings cannot be used to probe the endpoint.
- **Accept the key via `Authorization: Bearer` as well** — some infrastructure strips custom headers. Supporting both `Authorization: Bearer <key>` and `X-Public-Key: <key>` makes the integration more robust.
- **Validate the `Content-Type`** header and reject anything that is not `application/json`.
- **Enforce a body-size limit** (e.g. 512 KB) and return `413` for oversized requests. Without this, a single malformed request can tie up your handler.
- **Validate the event shape** before persisting — check that `type`, `timestamp`, and `sessionId` are present and have the expected types, and enforce a maximum length on all string fields. Reject or discard malformed events. A schema validation library (e.g. Zod) is well suited for this.
- **Cap the number of events per batch** request (e.g. 50). Combined with the body-size limit this prevents a single request from flooding your database.
- **Rate-limit per client or origin** to prevent abuse. Since events can arrive in bursts on fast navigation, allow short bursts but cap sustained throughput. Return `429` with a `Retry-After` header so clients back off gracefully.
- **Restrict allowed origins** if your endpoint only serves known domains. An optional per-client origin whitelist lets you return `403` for unexpected origins without breaking legitimate traffic.
- **Do not trust `sessionId`** as an authentication token — it is generated client-side and can be spoofed. Treat it as an opaque correlation ID only.
- **Never reflect raw event data** back to any client without sanitisation, as it originates from the browser.

### Event Shapes

All events share these base fields:

| Field | Type | Description |
|---|---|---|
| `type` | `string` | Event type identifier |
| `timestamp` | `string` | ISO 8601 UTC (`new Date().toISOString()`) |
| `sessionId` | `string` | Unique per browser-tab session, stored in `sessionStorage` |
| `path` | `string` | Current pathname (e.g. `/dashboard`) |

---

#### `page_view`

Fired on every client-side route change.

```json
{
  "type": "page_view",
  "timestamp": "2026-04-08T12:00:00.000Z",
  "sessionId": "abc123",
  "path": "/dashboard",
  "referrer": "google.com",
  "device": "desktop"
}
```

| Field | Type | Notes |
|---|---|---|
| `referrer` | `string \| undefined` | Hostname only, `www.` stripped. Omitted if no referrer. |
| `device` | `"mobile" \| "tablet" \| "desktop"` | Detected from `navigator.userAgent` |

---

#### `scroll`

Fired when the user leaves a page (route change or `pagehide`). Reports the furthest scroll position reached.

```json
{
  "type": "scroll",
  "timestamp": "2026-04-08T12:00:00.000Z",
  "sessionId": "abc123",
  "path": "/dashboard",
  "maxDepth": 75
}
```

| Field | Type | Notes |
|---|---|---|
| `maxDepth` | `number` | Integer 0–100 (percentage of page height scrolled) |

---

#### `web_vitals`

Fired on navigation or when the tab becomes hidden. Only sent when at least one metric was collected.

```json
{
  "type": "web_vitals",
  "timestamp": "2026-04-08T12:00:00.000Z",
  "sessionId": "abc123",
  "path": "/dashboard",
  "metrics": {
    "CLS": 0.05,
    "FCP": 1200,
    "LCP": 2400,
    "INP": 80,
    "TTFB": 300
  }
}
```

| Metric | Unit | Description |
|---|---|---|
| `CLS` | score (0–1) | Cumulative Layout Shift |
| `FCP` | ms | First Contentful Paint |
| `FID` | ms | First Input Delay |
| `INP` | ms | Interaction to Next Paint |
| `LCP` | ms | Largest Contentful Paint |
| `TTFB` | ms | Time to First Byte |

All `metrics` fields are optional — only the ones measured in the current session are included.

---

#### `anchor_navigation`

Fired when the URL hash changes (e.g. clicking an in-page anchor link).

```json
{
  "type": "anchor_navigation",
  "timestamp": "2026-04-08T12:00:00.000Z",
  "sessionId": "abc123",
  "path": "/docs",
  "anchor": "#getting-started"
}
```

| Field | Type | Notes |
|---|---|---|
| `anchor` | `string` | The full hash including `#` |

---

### Batch mode

When `batch` is enabled, multiple events are sent as a JSON array in a single request:

```json
[
  { "type": "page_view", "timestamp": "...", "sessionId": "...", "path": "/", "device": "desktop" },
  { "type": "scroll",    "timestamp": "...", "sessionId": "...", "path": "/", "maxDepth": 42 }
]
```

Your handler must handle both `{}` and `[{}, {}]` — see the example above.

## Notes

- Next.js App Router only.
- No `/api/collect` handler is included in this package.
- The consumer project must implement the collect endpoint itself.

## Development

```bash
pnpm install
pnpm typecheck
pnpm build
pnpm pack
```

Detailed publish steps are documented in `PUBLISHING.md`.