@addilytics/nextjs
v0.0.1
Published
Next.js server events and bundled App Router navigation tracking.
Readme
@addilytics/nextjs
Server-side analytics for Next.js App Router Route Handlers. The wrapper waits for the handler's real Response, records eligible HTML responses, and returns that same response without reading its body.
It does not track page.tsx renders through Proxy or Middleware. Those APIs run before rendering and cannot see the final status or content type, so treating them as pageviews would produce bad data.
Install
pnpm add @addilytics/nextjsWrap a Route Handler
// app/report/[id]/route.ts
import { withAddilytics } from '@addilytics/nextjs';
import type { NextRequest } from 'next/server';
async function report(request: NextRequest, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
return new Response(`<h1>Report ${id}</h1>`, {
headers: { 'content-type': 'text/html; charset=utf-8' }
});
}
export const GET = withAddilytics(report, {
endpoint: process.env.ADDILYTICS_ENDPOINT!,
siteKey: process.env.ADDILYTICS_KEY!
});The wrapper keeps the request type, context arguments, and response subtype. It also exposes its core client and a track method.
Client-side navigation
Server mode is the default and records only requests that reach a wrapped Route Handler. Next does not expose the final rendered response for normal App Router pages, so browser navigation uses client mode. The browser owns both the initial page and later committed navigations.
Create one server-only instance and expose its relay at the matching path:
// lib/analytics.server.ts
import { createNextAddilytics } from '@addilytics/nextjs';
export const analytics = createNextAddilytics({
endpoint: process.env.ADDILYTICS_ENDPOINT!,
mode: 'client',
siteKey: process.env.ADDILYTICS_KEY!
});// app/%5F_addilytics/route.ts
import { analytics } from '../../lib/analytics.server';
export const POST = analytics.relay;Mount the browser component once in the root layout. Suspense is required because the component
uses Next's usePathname and useSearchParams committed-navigation hooks.
import { AddilyticsBrowser } from '@addilytics/nextjs/browser';
import { Suspense } from 'react';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Suspense fallback={null}>
<AddilyticsBrowser mode="client" />
</Suspense>
</body>
</html>
);
}Next treats a route folder beginning with _ as private. Naming the folder %5F_addilytics exposes
the default /__addilytics URL. Client mode disables automatic pageviews in wrapped Route
Handlers, which prevents double-counting. The server and browser modes must both be client. If
you set relayPath on the server, pass the same path as endpoint to AddilyticsBrowser and place
the Route Handler there.
Your app bundles the browser helper; Addilytics does not load a hosted or CDN script. The browser sends only a generated event ID, timestamp, path, campaign query, and referrer to the same-origin relay. The site key stays in the server module. The relay rejects cross-origin requests, unexpected methods and content types, malformed fields, oversized bodies, and stale timestamps.
Next does not expose an HTTP status for committed client navigations. Those pageviews use status 0
to mean unknown instead of being mislabeled as successful responses. trackStatuses applies only to
wrapped Route Handler responses.
Back-forward cache restores are recorded. Hash-only changes are ignored unless you set
trackHashChanges: true. The lower-level useAddilyticsNavigation hook is available when a
component needs access to the browser tracker.
Custom events
Create one adapter when a route needs pageviews and custom events:
// app/checkout/route.ts
import { createNextAddilytics } from '@addilytics/nextjs';
const analytics = createNextAddilytics({
endpoint: process.env.ADDILYTICS_ENDPOINT!,
siteKey: process.env.ADDILYTICS_KEY!
});
export const POST = analytics.withRouteHandler(async (request) => {
await analytics.track(request, 'checkout_completed', {
props: { plan: 'pro' },
userId: 'your-stable-user-id'
});
return Response.json({ ok: true });
});Addilytics hashes userId with the site key before delivery. You can also set identify in the adapter options to resolve a user for every event.
Background delivery
Without a scheduler, the wrapper waits for analytics delivery before returning. Pass waitUntil when the deployment runtime has it:
import { waitUntil } from '@vercel/functions';
import { createNextAddilytics } from '@addilytics/nextjs';
const analytics = createNextAddilytics({
endpoint: process.env.ADDILYTICS_ENDPOINT!,
siteKey: process.env.ADDILYTICS_KEY!,
waitUntil
});Next 15.1 and newer also expose after. Adapt its callback API like this:
import { after } from 'next/server';
import { createNextAddilytics } from '@addilytics/nextjs';
const analytics = createNextAddilytics({
endpoint: process.env.ADDILYTICS_ENDPOINT!,
siteKey: process.env.ADDILYTICS_KEY!,
waitUntil: (promise) => after(() => promise)
});Check the hosting provider's support before choosing either API. If waitUntil throws synchronously, the adapter falls back to awaiting delivery. Analytics failures never replace a valid application response.
What counts
The default policy records GET responses with an HTML content type and either a 2xx or 404 status. It rejects bot traffic, redirects, server errors, JSON responses, /_next assets, prefetches, and React Server Component requests. ignorePaths, trackBots, and trackStatuses extend or replace parts of that policy.
A thrown Route Handler error has no final response, so the adapter rethrows it and records nothing. If you intentionally render an error response and want it counted, opt in with trackStatuses.
Runtime and caching limits
The package currently supports and tests Next.js 16. It uses only Request, Response, fetch, and Web Crypto through the core client, so the wrapper runs in both Node.js and Edge Route Handlers.
Keep the collector endpoint and site key in server-only modules. Client Components should import
only @addilytics/nextjs/browser.
Route Handler execution must happen for every request you want to count. Cached or statically generated handlers can serve responses without running the wrapper. On Next versions or deployments that cache a GET Route Handler, opt out in that route:
export const dynamic = 'force-dynamic';Server mode does not claim automatic pageview coverage for App Router pages, the Pages Router, Proxy, or Middleware. Those layers cannot see a final rendered response. Client mode covers committed App Router navigations through the browser component and same-origin relay.
