@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-nextMiddleware
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 / servicesThe 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 .envSee 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 gento refreshAranovaConversion, plus site handler wiring that callstrackConversion(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/blog— React-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 headlessonProgressmode).
// 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 replacement — components={{ 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 classes — classNames={{ 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, legacyGoogleAdsTracking, 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-cli—gentyped service unions (devDependency)
