@mitchdesigns/tracking
v1.10.0
Published
Meta Pixel + CAPI + GTM tracking for Next.js, with a one-command project installer.
Readme
@mitchdesigns/tracking
Meta Pixel + Conversions API (CAPI) + Google Tag Manager tracking for Next.js.
Every event fires both the browser Pixel and the server-side CAPI with a shared eventID, so Meta deduplicates them and counts each action once.
- Package:
@mitchdesigns/tracking - Source: github.com/mitchdesigns-com/tracking
- Full setup guide: SETUP.md
Install
pnpm dlx @mitchdesigns/tracking initThe installer is idempotent. It adds the dep, appends env vars to .env.example, wires transpilePackages, creates the CAPI route, and drops in MetaEventTemplates.tsx. Two things stay manual: fill in .env, and mount <MetaPixel /> in the root layout.
Manual install:
pnpm add @mitchdesigns/trackingEnvironment variables
NEXT_PUBLIC_META_PIXEL_ID=your_pixel_id
META_ACCESS_TOKEN=your_capi_access_token
NEXT_PUBLIC_META_PHONE_COUNTRY_CODE=20 # digits only; EG 20, UAE 971, KSA 966
NEXT_PUBLIC_META_DEFAULT_COUNTRY=EG # ISO 3166-1 alpha-2 for the market
NEXT_PUBLIC_META_TEST_EVENT_CODE=TESTxxxxx # remove before production
META_GRAPH_API_VERSION=v25.0 # optional; defaults to v25.0
META_ALLOWED_EVENTS=Purchase,AddToCart # optional; unset accepts any valid name
META_CAPI_RATE_LIMIT=60 # optional; requests per IP per minuteMETA_ACCESS_TOKEN must never get the NEXT_PUBLIC_ prefix — it stays server-only.
NEXT_PUBLIC_META_PHONE_COUNTRY_CODE is what makes phone matching work at all. Storefront forms collect national numbers (01012345678); Meta hashes E.164 digits (201012345678). Without the country code there is nothing to replace the trunk prefix with, so numbers are sent as typed and never match. NEXT_PUBLIC_META_DEFAULT_COUNTRY is sent as the country matching parameter on every event, browser and server — the cheapest match quality available, since no visitor has to type anything for it.
next.config.mjs
const nextConfig = {
transpilePackages: ["@mitchdesigns/tracking"],
};CAPI route — src/app/api/meta/capi/route.ts
export const runtime = "edge";
export { POST } from "@mitchdesigns/tracking/server";Root layout
Mount MetaPixelClient — not MetaPixel — and give it the tracking identity:
// src/components/meta/MetaPixelMount.tsx
"use client";
import { MetaPixelClient, useTrackingIdentity } from "@mitchdesigns/tracking";
import { useUser } from "@/features/auth/hooks/useUser"; // whatever your auth returns
export function MetaPixelMount() {
return <MetaPixelClient user={useTrackingIdentity(useUser().user)} />;
}// src/app/[locale]/layout.tsx
import { Suspense } from "react";
import { MetaPixelMount } from "@/components/meta/MetaPixelMount";
{process.env.NODE_ENV === "production" && process.env.NEXT_PUBLIC_META_PIXEL_ID && (
<Suspense>
<MetaPixelMount />
</Suspense>
)}<Suspense> is required — MetaPixel uses useSearchParams().
Advanced matching and Event Match Quality
Meta scores match quality per event instance, not per customer: an event with no user data counts against the score even when the same person identified themselves one page earlier. Attaching an identity only to signed-in customers therefore scores against the ~95% of storefront traffic that browses as a guest — the pixel this was built for reported Email on 3.92% of instances, Phone on 2.29%, First/Last name on 3.82% and External ID on 12.32%.
The fix is two halves, and both are needed:
1. Remember what the visitor already told you. Every form that legitimately collects identity — checkout step 1, the contact form, registration — calls rememberTrackingIdentity. It merges, so a later form never wipes an earlier field, and it lowercases the email to match what both the pixel and the Conversions API hash.
import { rememberTrackingIdentity } from "@mitchdesigns/tracking";
// in the submit handler, alongside the API call
rememberTrackingIdentity({ email, phone, firstName, lastName });Clear it on logout — a shared device must not attribute the next visitor's browsing to the last one:
import { clearTrackingIdentity } from "@mitchdesigns/tracking";
clearTrackingIdentity(); // in useLogout, next to the token teardown2. Feed that identity to every event. useTrackingIdentity(profile) merges the stored identity with the signed-in profile (the profile wins, and is written back), and returns the object useMetaTracking and MetaPixelClient both take:
const { user } = useUser();
const trackingUser = useTrackingIdentity(user);
const { trackAddToCart } = useMetaTracking(trackingUser);Pass any profile shape you like — id, email, phone / mobileNumber, firstName, lastName, gender, city, state, zip, country, and either dateOfBirth or dobYear / dobMonth / dobDay. Missing fields are simply omitted; gender is normalised to Meta's m / f, dates to YYYYMMDD, and anything that will not fit Meta's shape is dropped rather than guessed — a wrong hash is a non-match, not a partial one.
The hook is for the pixel, not for the events. getEnhancedUserData reads the same store synchronously at the moment an event is sent, so an event fired in a component's first effect — ViewContent on a product page is exactly that — still carries the identity. useTrackingIdentity hydrates from localStorage in an effect, one commit later; a component whose guard ref blocks the re-fire would otherwise have sent that event permanently anonymous. That single race is what held Email coverage on ViewContent to 1.43% of instances.
Two rules worth keeping
Do not pass
userto<MetaPixel />. Auserthat resolves after mount changes the identity oftrackPageView, andMetaPixelre-fires PageView when that changes — double-counting the same view.MetaPixelClientusesfbq('init', …)instead, which Meta merges into the pixel and applies to every subsequent browser event without re-firing anything.This rule used to also say the prop kept email off the Conversions API PageView, to avoid Meta's "duplicate client Emails" diagnostic. It no longer does either job: the identity store is read when an event is sent, so every server event now carries em/ph for a visitor who has identified themselves, PageView included. That is deliberate — Meta's own guidance is to send email on all events, and an anonymous PageView is a scored event with no match — but expect that diagnostic to reappear on a storefront where a handful of customers browse many pages each. It reports repetition, not error.
Keep the identity object referentially stable.
useMetaTrackingkeys its callbacks off it andMetaPixelre-fires PageView when they change.useTrackingIdentitymemoises on primitives; if you build the object yourself, do the same.
City, State and Zip are stored and sent when a project supplies them. Most checkouts hold city/area ids rather than names, so raising those coverage numbers means resolving the names at the call site first; country needs nothing beyond the env var.
Normalisation
Every matching parameter is normalised the way Meta normalises it before hashing, on write to the store and again on the server leg (idempotent, so a caller posting raw values still lands on the right hash):
| Parameter | Shape sent |
|---|---|
| em | lowercased, trimmed |
| ph | digits only, country code included, no trunk prefix |
| fn / ln | lowercased, punctuation and whitespace removed |
| ct / st | lowercased, letters only |
| zp | lowercased, alphanumeric only |
| country | ISO 3166-1 alpha-2, lowercased |
| ge | m / f |
| db | YYYYMMDD |
The browser and server legs are handed the same string for the same person, which is the only way the two hashes agree.
Usage — built-in events
Call the hook wherever a user action happens:
"use client";
import { useMetaTracking } from "@mitchdesigns/tracking";
export function AddToCartButton({ product, user }) {
const { trackAddToCart } = useMetaTracking(user);
return (
<button
onClick={() =>
trackAddToCart({
content_ids: [product.sku],
contents: [{ id: product.sku, quantity: 1, item_price: product.price }],
content_name: product.name,
value: product.price,
currency: "EGP",
})
}
>
Add to cart
</button>
);
}Available trackers on useMetaTracking(user?)
| Method | Meta event | Typical trigger |
|---|---|---|
| trackPageView() | PageView | Automatic via <MetaPixel /> |
| trackViewContent(data) | ViewContent | Product detail page |
| trackViewCategory(data) | ViewCategory (custom) | Category / shop page |
| trackAddToCart(data) | AddToCart | Add-to-cart handler |
| trackInitiateCheckout(data) | InitiateCheckout | Checkout page mount |
| trackAddPaymentInfo(data) | AddPaymentInfo | Payment step |
| trackPurchase(data) | Purchase | Thank-you page |
| trackContact() | Contact | Contact form submit |
| trackCompleteRegistration() | CompleteRegistration | After registration |
The MetaEventTemplates.tsx file (created by the installer) wraps the page-mount events (ProductViewTracking, InitiateCheckoutTracking, PurchaseTracking, CategoryViewTracking, AddPaymentInfoTracking) as drop-in components. Pass cartItems / products / user as props.
Passing user to the hook enriches CAPI match quality (email, phone, name, address are hashed automatically before leaving the server).
Purchase value excludes shipping
value on a purchase is the order total without shipping — the goods the
customer paid for, after discounts. Delivery fees are not revenue, so they go
out in their own field (shipping on the GA4 push) and never inside value.
Use the exported purchaseValue() helper when the only total you hold is the
shipping-inclusive one:
import { purchaseValue } from "@mitchdesigns/tracking";
trackPurchase({
orderId: orderNumber,
value: purchaseValue(order.total_amount, order.shipping_fees), // ex-shipping
content_ids,
contents,
numItems,
});The GA4 tracker takes the extras the spec asks for alongside it:
gtmTrackPurchase({
orderId: orderNumber,
value: purchaseValue(order.total_amount, order.shipping_fees),
shipping: order.shipping_fees,
tax: order.tax,
coupon: order.coupon?.code,
contents,
user,
});Adding a custom event
The built-in trackers cover the standard e-commerce funnel. For anything else — Lead, Subscribe, StartTrial, NewsletterSignup, or a fully custom name — compose the two primitives the package exports:
- Fire the browser pixel with
window.fbq(...). - Fire the server via
sendCAPIEvent(...). - Share a single
eventIDbetween them so Meta deduplicates.
Option A — inline, one-off
"use client";
import { useMetaTracking } from "@mitchdesigns/tracking";
import { generateEventId, sendCAPIEvent, getEnhancedUserData } from "@mitchdesigns/tracking";
export function NewsletterForm({ user }) {
const enhancedUserData = getEnhancedUserData(user);
async function onSubmit(email: string) {
const eventId = generateEventId("lead");
// 1. Browser Pixel
if (typeof window !== "undefined" && window.fbq) {
window.fbq("track", "Lead", { content_name: "Newsletter" }, { eventID: eventId });
}
// 2. Server CAPI (shares eventId → deduped)
sendCAPIEvent({
eventName: "Lead",
eventId,
customData: { content_name: "Newsletter" },
userData: { ...enhancedUserData, em: email },
});
}
// ...
}- Use
"track"for Meta's standard events (Lead,Subscribe,Search,StartTrial,SubmitApplication, ...). - Use
"trackCustom"for a name Meta doesn't know ("NewsletterSignup","VideoWatched75", ...). - The
generateEventId(prefix)prefix is free-form — it's just for readability in Events Manager.
Option B — reusable wrapper (recommended)
Add the tracker next to the built-in ones so callers don't have to remember the pixel/CAPI dance. Create src/hooks/useCustomTracking.ts in your app:
"use client";
import { useCallback } from "react";
import {
useMetaTracking,
generateEventId,
sendCAPIEvent,
getEnhancedUserData,
} from "@mitchdesigns/tracking";
export function useCustomTracking(user?: any) {
const built = useMetaTracking(user);
const enhancedUserData = getEnhancedUserData(user);
const trackLead = useCallback(
(data: { content_name: string; value?: number; currency?: string }) => {
const eventId = generateEventId("lead");
if (typeof window !== "undefined" && window.fbq) {
window.fbq("track", "Lead", data, { eventID: eventId });
}
sendCAPIEvent({
eventName: "Lead",
eventId,
customData: data,
userData: enhancedUserData,
});
},
[enhancedUserData],
);
return { ...built, trackLead };
}Then swap useMetaTracking for useCustomTracking at call sites — all the built-in trackers plus your new trackLead are available on the same object.
Server-only custom events
For events triggered outside the browser (webhooks, cron jobs, backend order completion), call sendCAPIEvent from a server route or action. The pixel side is skipped — Meta will still count the event, just without a browser match.
// e.g. inside a webhook handler
import { sendCAPIEvent } from "@mitchdesigns/tracking";
await sendCAPIEvent({
eventName: "Subscribe",
eventId: `sub_${subscriptionId}`,
customData: { value: 29, currency: "USD", predicted_ltv: 348 },
userData: { em: hashedEmail }, // must be pre-hashed if you're calling directly
});Checklist for a new event
- [ ] Standard-event name if Meta has one; otherwise
trackCustom. - [ ] Same
eventIDin thefbqcall and thesendCAPIEventcall. - [ ]
userDatapassed (viagetEnhancedUserData(user)) for match quality. - [ ] Verified live in Events Manager → Test Events with
NEXT_PUBLIC_META_TEST_EVENT_CODEset.
Testing locally
CAPI works on localhost; the pixel is production-only (Meta doesn't accept localhost as a domain).
- Set
NEXT_PUBLIC_META_TEST_EVENT_CODE=TESTxxxxxin.env. - Open Events Manager → Test Events.
- Trigger the action — events land in real time with match-quality scores.
- Remove the test code before deploying.
GTM
GTM / GA4 helpers live in a separate entry point, @mitchdesigns/tracking/gtm:
import {
GTMScript,
useGTMTracking,
pushToDataLayer,
pushEcommerceEvent,
contentsToGA4Items,
buildGTMUserData,
} from "@mitchdesigns/tracking/gtm";Why a subpath? As of
1.4.0these helpers are no longer re-exported from the main barrel (@mitchdesigns/tracking). Meta-Pixel-only apps therefore never pull the GTMdataLayercode into their type program. This keeps the package compatible with@next/third-parties(GoogleTagManager), which also declaresWindow.dataLayer— previously importing both in the same app produced aTS2717"Subsequent property declarations must have the same type" build error. The GTM helpers no longer augment the globalWindowat all, so they can coexist with@next/third-parties.
See SETUP.md for the full guide.
License
UNLICENSED — internal use for MitchDesigns projects.
