@iorg1/trackking-next
v0.5.0
Published
Cookieless, server-side page-view tracking for Next.js. Drop-in middleware that beacons visits to a Trackking collector — no cookies, no client-side script.
Maintainers
Readme
@iorg1/trackking-next
Cookieless, server-side page-view tracking for Next.js.
@iorg1/trackking-next is a tiny Next.js middleware that beacons each page navigation to
a Trackking collector over HTTPS. There is
no cookie, no client-side script, and no consent banner machinery — the
middleware runs on the server (Edge runtime), reads the request headers, and
fires a fire-and-forget POST. Because it observes the document request, it also
sees bots and AI crawlers (Googlebot, GPTBot, ClaudeBot, PerplexityBot) that
JavaScript-based analytics never record.
Install
npm install @iorg1/trackking-nextConfigure
Set two environment variables in your Next.js app:
TRACKKING_ENDPOINT=https://collector.example.com/collect
TRACKKING_API_KEY=your_site_api_keyOptional:
# Reverse proxies in front of your app — trust the right client IP (see Options).
TRACKKING_TRUSTED_PROXY_HOPS=1
# Enable the first-party client-event route (see "First-party client events").
TRACKKING_EVENT_ROUTE=/data/:event
# Collector event endpoint; defaults to TRACKKING_ENDPOINT with /collect → /event.
TRACKKING_EVENT_ENDPOINT=https://collector.example.com/eventUse
The whole middleware (simplest)
// middleware.ts
import { createTrackingMiddleware } from "@iorg1/trackking-next";
export const middleware = createTrackingMiddleware();
export const config = {
// Track real page navigations; skip Next internals, the API, and static files.
matcher: ["/", "/((?!_next/|api/|.*\\..*).*)"],
};Inside an existing middleware
Already have a middleware.ts (locale negotiation, auth, …)? Call track() and
return your own response:
import { NextResponse, type NextRequest, type NextFetchEvent } from "next/server";
import { track } from "@iorg1/trackking-next";
export function middleware(req: NextRequest, event: NextFetchEvent) {
track(req, event); // fire-and-forget, never throws
// ...your existing logic...
return NextResponse.next();
}First-party client events
Page-view tracking is fully server-side, but sometimes you want to record a client-triggered event — a signup click, a preview generated, a "real browser rendered this" ping. Instead of beaconing the collector's origin from the browser (blocked by many ad/tracker blockers, and needing CORS), expose a same-origin route and let the middleware forward the event server-side. Your API key never reaches the browser.
Set eventRoute (or TRACKKING_EVENT_ROUTE) and add the route to your matcher.
When a request matches, handleEvent records a named event — the captured
segment is the event name — and answers the client 200 {}; when it doesn't, it
returns null so you fall through to normal tracking:
import { NextResponse, type NextRequest, type NextFetchEvent } from "next/server";
import { track, handleEvent } from "@iorg1/trackking-next";
export async function middleware(req: NextRequest, event: NextFetchEvent) {
const res = await handleEvent(req, event, { eventRoute: "/data/:event" });
if (res) return res; // it was a /data/<event> beacon → 200 {}
track(req, event);
return NextResponse.next();
}
export const config = {
// Add the event route to the matcher so the middleware runs for it.
matcher: ["/", "/data/:path*", "/((?!_next/|api/|.*\\..*).*)"],
};createTrackingMiddleware() does this for you automatically when eventRoute /
TRACKKING_EVENT_ROUTE is set — just remember to add the route to the matcher.
From the browser, POST to the route with an optional { path?, props? } body:
navigator.sendBeacon(
"/data/signup",
new Blob([JSON.stringify({ props: { plan: "pro" } })], { type: "application/json" }),
);The collector records { name: "signup", path, props } (props are re-sanitized
server-side). Route templates support :name (one path segment) and * (the
rest of the path). For full control, pass resolveEvent: (req) => string | null
instead of eventRoute — return the event name, or null to pass through.
Conversions
A conversion is an event that gets attributed to the campaign the visitor
landed with. Page views already carry the landing query string, and the collector
turns it into a campaign label (utm_campaign, Google's auto-appended
gad_campaignid, a bare gclid, …). A conversion adds the request's client IP +
User-Agent; the collector hashes them into the same cookieless daily visitor id
the page view got, discards the IP, and looks up that visitor's landing campaign
for the day. Nothing is stored on the device, so a click and its conversion are
linked only when they happen on the same day.
Three ways to record one:
// 1. From a route handler or server action, where the action actually completes.
// Use @iorg1/trackking-generic and pass the request headers:
import { headers } from "next/headers";
import { trackConversion } from "@iorg1/trackking-generic";
await trackConversion("report-ordered", { headers: await headers(), path: "/order" });
// 2. From your middleware — e.g. when the thank-you page is requested.
// (A page request repeats on reload, so prefer 1 when you can.)
import { track, trackConversion } from "@iorg1/trackking-next";
export function middleware(req: NextRequest, event: NextFetchEvent) {
if (req.nextUrl.pathname === "/order/confirmed") trackConversion(req, event, "order");
track(req, event);
return NextResponse.next();
}
// 3. From the browser, through the first-party event route:
navigator.sendBeacon("/data/signup?conversion=1");
// …or with a body: { conversion: true, props: { plan: "pro" } }If your code already knows the campaign (carried in a hidden form field, say),
pass campaign: "…" and the collector skips the lookup. Plain event beacons stay
identity-free unless you set identifyEvents: true, in which case they, too, are
attributed to the visitor's campaign.
Options
createTrackingMiddleware({
endpoint: process.env.TRACKKING_ENDPOINT, // default
apiKey: process.env.TRACKKING_API_KEY, // default
shouldTrack: (req) => !req.nextUrl.pathname.startsWith("/admin"),
getClientIp: (req) => req.headers.get("cf-connecting-ip"),
trustedProxyHops: 1, // trust the right x-forwarded-for entry (see below)
eventRoute: "/data/:event", // first-party client-event route (see above)
resolveEvent: (req) => …, // …or fully custom event extraction (overrides eventRoute)
identifyEvents: false, // attach visitor identity to plain event beacons too (see Conversions)
eventEndpoint: process.env.TRACKKING_EVENT_ENDPOINT,
debug: process.env.NODE_ENV !== "production", // verbose logging (see below)
});The middleware automatically skips non-GET requests and Next.js prefetches
(the event route is exempt — it handles the beacon method you send).
trustedProxyHops
x-forwarded-for is appended left→right as a request passes through proxies, and
the leftmost entry is client-controlled (spoofable). This option is the
number of reverse proxies you run in front of the app, so the middleware trusts
the value your proxy appended:
0(default) — use the leftmost entry. Correct only on a platform that overwrites XFF for you (e.g. Vercel, Cloudflare).1+— use the entry that many hops from the right. Behind your own Caddy/nginx/Traefik, set this (usually1) so a spoofed leftmost entry can't poison the visitor hash or geo/ASN lookup.
Falls back to TRACKKING_TRUSTED_PROXY_HOPS. Ignored when getClientIp is set.
Debugging
Tracking is intentionally silent — it never throws and, by default, never logs,
so a misconfiguration (wrong endpoint, missing key, an unexpected 4xx) looks
like "nothing happens." To see what it's doing, set TRACKKING_DEBUG:
TRACKKING_DEBUG=1 # any truthy value; "0" / "false" / "" disable it(or pass debug: true in the config, which overrides the env var). For each
request the middleware then logs to the console via console.debug, prefixed
with [trackking]:
- the resolved config — endpoint, a masked API key (first 6 chars + length, never the full secret), and which config keys you passed;
- the decision — tracked, or skipped with the reason (not configured,
non-
GET, prefetch, orshouldTrack()returned false); - the event payload that gets posted; and
- the collector's response —
POST <endpoint> → <status> <statusText>, plus the response body on a non-2xx, or the error if the request threw.
[trackking] GET /pricing { endpoint: 'https://…/collect', apiKey: 'tk_abc…(35 chars)', configKeys: [] }
[trackking] tracking event: { path: '/pricing', host: 'example.com', referrer: null, userAgent: '…', ip: '203.0.113.10', headers: { … } }
[trackking] POST https://…/collect → 204 No Content (/pricing)Leave it unset in production: it logs request metadata (path, IP, user-agent) to your server logs.
What gets sent
Page views — a single JSON object: { path, query, host, referrer, userAgent,
ip, headers }. The collector hashes ip into a daily-rotating visitor id and
discards it — the raw IP is never stored. The referrer is truncated to its
host. query is the landing query string; the collector derives the campaign
label from it and stores it with secret-looking parameter values (token,
code, email, …) and, by default, click-id values (gclid, fbclid, …)
redacted. headers is a small bag of low-entropy request headers
(Accept-Language, Accept, the Sec-Fetch-* fetch-metadata set, and
Sec-CH-UA* client hints) that help the collector tell real browsers from bots —
it is not a device fingerprint, and no cookies or client identifiers are
involved.
Events (if you use the event route) — { name, path, props }, carrying no
visitor identity. props is an optional small, flat bag of non-PII metadata,
re-sanitized by the collector.
Conversions — the same, plus conversion: true and the request's ip +
userAgent, which the collector hashes into the daily visitor id (IP discarded)
to attribute the conversion to a campaign. Plain events carry identity only with
identifyEvents: true.
No personal identifiers are persisted.
License
MIT
