npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@aranova/tracking-next

v0.20.1

Published

Next.js tracking utilities for Aranova client sites

Readme

@aranova/tracking-next

Next.js tracking utilities for Aranova client sites. Use this package for App Router installs where attribution cookies should be captured before client JavaScript runs.

Install

npm install @aranova/tracking-next

Middleware

Install the tracking middleware so gclid, fbclid, and UTM params are written to cookies from the first HTTP response.

// middleware.ts
import { createTrackingMiddleware } from "@aranova/tracking-next/middleware";

export default createTrackingMiddleware();

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

Next 16: the middleware file convention is deprecated in favor of proxy. createTrackingMiddleware() returns a convention-agnostic handler, so no code change is needed — just rename the file to proxy.ts and keep the same default export and config:

// proxy.ts
import { createTrackingMiddleware } from "@aranova/tracking-next/middleware";

export default createTrackingMiddleware();

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

middleware.ts still works during the transition; use proxy.ts on Next 16+ to silence the deprecation warning.

Provider

Create one shared tracking module and use its scoped exports throughout your app.

// lib/tracking.ts
"use client";

import { createTracking } from "@aranova/tracking-next";
import { ARANOVA_TRACKING_CONFIG } from "@/aranova-services";

export const { TrackingProvider, useTracking } = createTracking({
  apiKey: process.env.NEXT_PUBLIC_ARANOVA_TRACKING_API_KEY!,
  endpoint: process.env.NEXT_PUBLIC_ARANOVA_TRACKING_ENDPOINT!,
  trackingConfig: ARANOVA_TRACKING_CONFIG,
  triggers: {
    automatic: {
      page_view: {},
      time_on_site: { thresholdSeconds: 60 },
      specific_page_visit: {
        pages: [
          { name: "contact_page", pathPattern: /^\/(contact|book|get-started)/ },
          { name: "google_ads_service_page", pathPattern: /^\/services\/google-ads\/?$/ },
        ],
      },
    },
    manual: {
      form_submit: {},
      phone_click: {},
      cta_click: {},
    },
  },
  debug: process.env.NODE_ENV !== "production",
});

Wrap the root layout.

// app/layout.tsx
import { AdPlatformTracking } from "@aranova/tracking-next";
import { ARANOVA_TRACKING_CONFIG } from "@/aranova-services";
import { TrackingProvider } from "@/lib/tracking";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <AdPlatformTracking trackingConfig={ARANOVA_TRACKING_CONFIG} />
      </head>
      <body>
        <TrackingProvider>{children}</TrackingProvider>
      </body>
    </html>
  );
}

lib/tracking.ts needs the "use client" directive above because createTracking() creates React context — the module that calls it is a Client Component. Your root layout stays a Server Component and just renders the client <TrackingProvider>.

No consumer <Suspense> boundary is required. The provider reads useSearchParams() only inside its own internal <Suspense> boundary, so it never opts the enclosing route out of static prerendering. A fully static page (including one with export const dynamic = 'error') builds cleanly and stays ○ (Static) with <TrackingProvider> in the root layout.

Tracking is on by default (consent v2, opt-out model) — no banner. Add a footer "cookie preferences" control built on useCookiePreferences() (see Consent below) and disclose the tracking + opt-out in the site's privacy policy.

Legacy static gtag IDs

gtagId, gtagIds, GoogleAdsTracking, and generated ARANOVA_GTAG_IDS remain compatibility APIs. Do not use them for new installs: static IDs cannot consume dashboard tag changes or tombstones without a redeploy. If maintaining a legacy install, gtagIds loads every entry and takes precedence over gtagId.

<GoogleAdsTracking gtagIds={{ production: "AW-111111111", test: "AW-222222222" }} />

Manual Events

Import useTracking from your local lib/tracking module, not directly from the package.

"use client";

import { useTracking } from "@/lib/tracking";

export function LeadForm() {
  const tracking = useTracking();

  return (
    <form
      id="lead-form"
      action="/api/lead"
      onSubmit={(event) => {
        const form = event.currentTarget;
        const data = new FormData(form);

        tracking.trackEvent("form_submit", {
          form: {
            id: form.id,
            action: form.getAttribute("action"),
            fields: [
              {
                name: "service_interest",
                type: "select",
                label: "Service interest",
                value: String(data.get("service_interest") ?? ""),
              },
              {
                name: "is_existing_patient",
                type: "checkbox",
                label: "Existing patient",
                value: data.get("is_existing_patient") === "on",
              },
            ],
          },
          page: { path: window.location.pathname },
        });
      }}
    >
      {/* fields */}
    </form>
  );
}

fields[].value can be any JSON value: string, number, boolean, null, array, or object and is stored as first-party JSONB. Intentionally submitted lead fields may include raw names, emails, phone numbers, addresses, selections, free-text messages, and submitted file data for first-party analytics and lead operations. Build the array explicitly from the successful submission; the SDK never scrapes arbitrary DOM fields. File/Blob objects must be converted to a JSON representation, and the complete event metadata must fit the 4 KB limit; upload larger files separately and send their storage reference. Never send passwords, authentication tokens, payment-card/bank credentials, or private keys. Apply the client's privacy notice, consent, retention, and regulated-data requirements. Google offline matching uses normalized, server-side SHA-256-hashed identifiers — not raw free-text/file metadata.

Phone clicks (tel: taps) — manual or auto-capture

Opt into auto-capture and every tel: link is tracked with no per-link code. Add autoCapture to the phone_click registration — a single delegated click listener does the rest:

manual: {
  phone_click: { autoCapture: {} }, // default selector: a[href^="tel:"]
  // or narrow it: { autoCapture: { selector: "a.call" } }
},
<a href="tel:+14165550199" data-aranova-section="header">
  (416) 555-0199
</a>

The number is read from the href (normalized to E.164); section comes from an optional data-aranova-section. Unlike cta_click, a phone_click also fires the Google Ads conversion for a linked phone-call goal (a CLICK_TO_CALL action) — auto-fired the moment the tap is captured, so you never call trackConversion (firing needs the sales client wired with the same config; see below).

Prefer to instrument links yourself? Fire it from a click handler — this fires the conversion the same way:

tracking.trackEvent("phone_click", {
  phone_number: "+14165550199",
  page: { path: window.location.pathname },
  section: "header",
});

CTA clicks — manual or auto-capture

Fire cta_click yourself for full control over the name:

tracking.trackEvent("cta_click", {
  cta_name: "Book appointment",
  section: "hero",
  destination_url: "/booking",
  page: { path: window.location.pathname },
});

Or opt into auto-capture and skip per-button code. Add autoCapture to the cta_click registration — this attaches a single delegated click listener:

manual: {
  cta_click: { autoCapture: {} }, // default selector: [data-aranova-cta]
  // or target existing classes: { autoCapture: { selector: "a.cta, .btn-primary" } }
},

Then tag your CTAs in markup — no imports, no handlers:

<a href="/booking" data-aranova-cta="Book now" data-aranova-section="hero">Book now</a>
<button data-aranova-cta="Get a quote">Get a quote</button>

cta_name resolves to the data-aranova-cta value, else the element's trimmed text (capped 120 chars), else tag#id. section comes from an optional data-aranova-section; href/destination_url and a short element descriptor are captured automatically. Point the selector at real CTAs (an explicit attribute is the safe default) — every matching click is counted, including ones that later stopPropagation.

Automatic dwell + exit (page_exit)

page_exit is captured automatically — no registration. On route change, tab hide, and page unload it records the active (visible) time spent on the page plus the max scroll depth reached, delivered via a keepalive beacon so the final page still counts. This powers per-page dwell and drop-off in the dashboard Analytics tab; there is nothing to configure.

Phone Fields

Bundled libphonenumber-js: parse/format utils + a React input. Display is configurable; the value sent to the backend is always E.164. Configure once via createTracking({ phone: { defaultCountry: 'CA', display: 'national' } }).

"use client";
import { usePhoneField, PhoneField, toE164 } from "@aranova/tracking-next";

const phone = usePhoneField(); // phone.value (display), phone.e164 (wire), .isValid, .error
<input {...phone.inputProps} />; // or the batteries-included <PhoneField name="phone" />

Pure, isomorphic utils (usable in Server Components / route handlers) are at @aranova/tracking-next/phone. Full guide: phone.md.

Server Reads

Read attribution cookies from Server Components or server actions.

import { getTrackingParamsServer } from "@aranova/tracking-next/server";

export default function Page() {
  const { gclid, utm_source } = getTrackingParamsServer();
  return gclid || utm_source === "google" ? <GoogleLanding /> : <OrganicLanding />;
}

Recording Sales

Record a sale / conversion (a first-class, mutable resource — not a fire-and-forget event). Money is integer minor units (cents); currency is a required ISO-4217 enum.

One isomorphic createSalesClient (on the root entry) serves both sides — what a key may do is enforced by the backend, not by hiding methods. Browser write with your public key, from a client component:

"use client";
import { createSalesClient, toMinor } from "@aranova/tracking-next";

const sales = createSalesClient({
  apiKey: process.env.NEXT_PUBLIC_ARANOVA_TRACKING_API_KEY!,
  endpoint,
});
await sales.record({ currency: "CAD", amount_total_cents: toMinor(250, "CAD"), service: "tires" });

Full CRUD from a route handler / server action / Server Component with your secret key. Import from the /sales subpath — it's React-free, so it loads in an RSC or any server module without dragging the provider/components into the server graph (keep the secret key in server env, never in client code):

import { createSalesClient } from "@aranova/tracking-next/sales";
import type { AranovaService } from "./aranova-services"; // generated, see below

const sales = createSalesClient<AranovaService>({
  apiKey: process.env.ARANOVA_TRACKING_SECRET_KEY!,
  endpoint: process.env.ARANOVA_TRACKING_ENDPOINT!,
});
await sales.list({ sort: "amount_total_cents", want_total: true }); // → { items, total_count, … }
await sales.summary({ range: "mtd", timezone: "America/Toronto", compare_to: "previous_period" });
await sales.customers.list({ segment: "returning", sort: "total_spent" }); // phone-keyed roster
const cfg = await sales.business.config(); // tz / currencies / services

The root entry (@aranova/tracking-next) still re-exports createSalesClient and the money/date helpers for back-compat, so existing imports keep working — but prefer /sales in server code so the React surface never reaches your server bundle.

Dashboard reads are all currency-grouped (never summed). summary() gains tz-aware calendar/custom ranges, granularity, and period-over-period compare_to (legacy 24h/7d/30d unchanged); a customer is their phone (E.164), so customers.* is a live roster with no separate table. A public-key client calling any read gets a 403.

Generate the typed AranovaService union from your dashboard services with the CLI (install it as a devDependency):

npm install --save-dev @aranova/tracking-cli
npx @aranova/tracking-cli gen          # reads ARANOVA_TRACKING_SECRET_KEY from .env

See the full guide: sales-tracking.md and the CLI reference: cli.md.

On-site conversion firing

Run tracking-cli gen once to emit ARANOVA_TRACKING_CONFIG, then use that reference everywhere Google tracking is initialized. R2 is authoritative: the SDK confirms the current object before emitting Google tag, page-view, or conversion commands. Dashboard tag changes, goal remaps, and disabled-state tombstones propagate under public, max-age=60, must-revalidate with no CLI run or site redeploy.

import {
  createSalesClient,
  createTracking,
  getTrackingConfigRuntime,
  toMinor,
} from "@aranova/tracking-next";
import {
  ARANOVA_TRACKING_CONFIG,
  type AranovaConversion,
  type AranovaService,
} from "./aranova-services";

const googleTracking = getTrackingConfigRuntime(ARANOVA_TRACKING_CONFIG);

export const { TrackingProvider, useTracking } = createTracking({
  apiKey,
  endpoint,
  triggers,
  trackingConfig: ARANOVA_TRACKING_CONFIG,
});

const sales = createSalesClient<AranovaService, AranovaConversion>({
  apiKey,
  endpoint,
  firing: googleTracking,
});

await sales.recordSale({
  currency: "CAD",
  amount_total_cents: toMinor(250, "CAD"),
  service: "tires",
}); // records + fires
sales.trackConversion("phone_click"); // fires a manual event-goal only (no sale recorded)
  • Automatic event-goals fire when their registered detector crosses the published threshold.
  • Sales + manual goals use the shared runtime above. Firing is consent-gated, transaction-deduped, and browser-only.
  • A remap of an existing key needs no codegen. A new manual goal key still requires tracking-cli gen to refresh AranovaConversion, plus site handler wiring that calls trackConversion(key).

ARANOVA_CONFIG_URL and conversionConfig: { cdnUrl } remain legacy compatibility APIs; do not use them for new installs. See conversion-config-schema.md.

Consent (v2 — opt-out model)

Tracking is on by default: the consent-default <Script> injected by GoogleAdsTracking / AdPlatformTracking resolves the visitor's effective consent synchronously from localStorage before gtag.js loads — granted unless an explicit, unexpired decline is stored. A decline is honored for 90 days; a grant never expires. There is no pending state and the package ships no consent UI — each site provides its own footer "cookie preferences" control and discloses tracking in its privacy policy (that notice is what makes the opt-out model defensible).

Footer control — useCookiePreferences()

"use client";
import { useCookiePreferences } from "@aranova/tracking-next";

function CookiePreferences() {
  const { isDenied, optOut, optIn } = useCookiePreferences();

  return isDenied ? (
    <button onClick={optIn}>Enable ad measurement</button>
  ) : (
    <button onClick={optOut}>Opt out of ad measurement</button>
  );
}

The hook returns the effective state ("granted" | "denied"), its source ("default" = no explicit choice, "explicit"), boolean helpers (isDefault / isGranted / isDenied), the choice's updatedAt / expiresAt, and the optOut() / optIn() / reset() actions. It stays in sync with other components in the same tab (via onConsentChange) and other tabs (via storage events). Plain-function equivalents (optOut, optIn, resetConsent, getConsentChoice, onConsentChange) are exported for non-component contexts.

Deprecated opt-in flow

<ConsentBanner />, useConsent(), and useConsentState() are @deprecated and scheduled for removal. The banner is permanently inert (it rendered only while consent was pending, which no longer occurs); useConsent() still works as a shim (isPending is always false; accept/decline map to optIn/optOut). The old consent-restore scripts are gone — the consent-default script already applies the effective state.

Migrating from the banner flow: delete <ConsentBanner />, add a footer control built on useCookiePreferences, and mention the tracking + opt-out in your privacy policy. Existing visitors' stored grants stay granted; stored declines stay denied for 90 days from their first visit after the upgrade.

Blog Rendering

Render Aranova-published blog posts straight from the CDN — no API calls, no database. Three subpaths:

  • @aranova/tracking-next/blogReact-free: createBlogClient, theme presets + createBlogFontTheme, SEO builders (buildBlogPostMetadata, buildBlogPostingJsonLd, buildBlogSitemapEntries), TOC/reading-time helpers. Safe in metadata, sitemaps, client bundles.
  • @aranova/tracking-next/blog-server — the RSC renderers <BlogPost/> / <BlogPostView/> + article chrome (Server Components only).
  • @aranova/tracking-next/blog-client — animated islands: TocPanel (scroll-spy TOC), ReadingProgress (progress bar with a headless onProgress mode).
// lib/blog.ts
import { createBlogClient } from "@aranova/tracking-next/blog";
export const blogClient = createBlogClient({
  apiKey: process.env.NEXT_PUBLIC_ARANOVA_TRACKING_API_KEY!,
  cdnBaseUrl: process.env.NEXT_PUBLIC_ARANOVA_BLOG_CDN_URL!,
  namespace: process.env.NEXT_PUBLIC_ARANOVA_BLOG_NAMESPACE!, // Aranova provides it
});

// lib/blog-theme.ts — start from a preset, wire your next/font vars
import { BLOG_THEME_PRESETS, createBlogFontTheme } from "@aranova/tracking-next/blog";
export const BLOG_THEME = createBlogFontTheme(BLOG_THEME_PRESETS.modern, {
  heading: "var(--font-display)",
  body: "var(--font-sans)",
});

// app/blog/[slug]/page.tsx (App Router)
import { BlogPost } from "@aranova/tracking-next/blog-server";

export const revalidate = 300; // re-publishes appear within 5 minutes
export const dynamicParams = true;
export async function generateStaticParams() {
  return blogClient.getStaticParams();
}

export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  return (
    <BlogPost
      slug={slug}
      client={blogClient}
      defaultTheme={BLOG_THEME}
      showMeta
      showToc
      tocSide="right" // or "left"
      tocCollapse="vertical" // or "horizontal" (collapses to a chevron chip)
      showProgress
      showBreadcrumbs
      breadcrumbs={[
        { label: "Home", href: "/" },
        { label: "Blog", href: "/blog" },
      ]}
    />
  );
}

Index pages: blogClient.listPosts() returns manifest entries with title, description, published_at, reading_minutes, and hero_image — everything an image-card grid needs without fetching content objects. buildBlogPostMetadata / buildBlogPostingJsonLd / buildBlogSitemapEntries cover generateMetadata, JSON-LD, and sitemap.ts.

Customization, cheapest first: the theme (defaultTheme, deep-partial: palette incl. optional dark, responsive type scale, spacing, radius, caption_align); chrome props (above); component replacementcomponents={{ Cta: MyCtaSection, ... }} swaps the renderer for any MDX component (Cta/Callout/BlogImage/FaqItem/KeyTakeaways, keep data-aranova-cta={label} on custom CTAs so cta_click auto-capture keeps firing); per-surface classesclassNames={{ article, cta, toc, meta, ... }} merged onto the stable .aranova-blog-* classes. The islands are importable directly for fully custom compositions — e.g. <ReadingProgress showBar={false} onProgress={(f, pct) => ...} /> to drive your own indicator.

Reads are best-effort: network/parse failures degrade to null/empty — a blog page never crashes the host site.

Exports

  • Root package: createTracking, TrackingProvider, useTracking, AdPlatformTracking, getTrackingConfigRuntime, legacy GoogleAdsTracking, consent v2 (useCookiePreferences, optIn, optOut, resetConsent, getConsentChoice, onConsentChange) + deprecated shims (ConsentBanner, useConsent, useConsentState), attribution hooks, event types; createSalesClient() (isomorphic — public key writes; secret key reads/CRUD, summary, customers.*, business.config) + toMinor/fromMinor/formatMoney/formatDateInTz; phone (parsePhone/toE164/formatPhone/phoneField, usePhoneField, PhoneField)
  • @aranova/tracking-next/sales: React-free server-safe SDK — createSalesClient, money/date helpers, and all sale/customer/config types. Use this in Server Components, route handlers, and Node servers; the root entry re-exports the same symbols for back-compat.
  • @aranova/tracking-next/middleware: createTrackingMiddleware()
  • @aranova/tracking-next/server: getTrackingParamsServer()
  • @aranova/tracking-next/phone: isomorphic phone utils (no React)
  • @aranova/tracking-next/blog / /blog-server / /blog-client: CDN blog rendering (see Blog Rendering above)
  • Codegen: @aranova/tracking-cligen typed service unions (devDependency)