@pakicetus97/next-analytics
v0.2.3
Published
App Router-only analytics provider for Next.js that posts events to a configurable collect endpoint.
Maintainers
Readme
@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 in the Implementing the Collect Endpoint section below.
Installation
pnpm add @pakicetus97/next-analyticsUsage
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:
<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
// 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
// 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
// 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
<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 setThe 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-Keyheader if you use thepublicKeyprop. Reject requests with a missing or invalid key with401. 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: Beareras well — some infrastructure strips custom headers. Supporting bothAuthorization: Bearer <key>andX-Public-Key: <key>makes the integration more robust. - Validate the
Content-Typeheader and reject anything that is notapplication/json. - Enforce a body-size limit (e.g. 512 KB) and return
413for oversized requests. Without this, a single malformed request can tie up your handler. - Validate the event shape before persisting — check that
type,timestamp, andsessionIdare 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
429with aRetry-Afterheader so clients back off gracefully. - Restrict allowed origins if your endpoint only serves known domains. An optional per-client origin whitelist lets you return
403for unexpected origins without breaking legitimate traffic. - Do not trust
sessionIdas 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.
{
"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.
{
"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.
{
"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).
{
"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:
[
{ "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/collecthandler is included in this package. - The consumer project must implement the collect endpoint itself.
Development
pnpm install
pnpm typecheck
pnpm build
pnpm packDetailed publish steps are documented in PUBLISHING.md.
