brokeinprod
brokeinprod
Theme

Debugging

We Added Tracking to Our Shared Component. Half the Clicks Never Fired.

We added click tracking to our shared link component, and half the clicks never fired, because two sibling components rendered the same link themselves.

November 12, 2026

The GA4 Realtime panel showed affiliate_click events streaming in, but only two of the five link placements ever appeared. The other three worked. They routed, they redirected, they converted. As far as analytics was concerned, they did not exist.

Start here: If analytics events fire from some placements of a link but not others, the likely cause is not the tracking code. It is that not every placement goes through the tracking code. A "shared" component only centralizes behavior if it is the only code path that renders that kind of link. Any sibling component that builds the same <Link> itself silently bypasses everything you later add to the shared one. The fix is delegation: refactor the shared component to accept context overrides, then make every composed component render through it.

The setup

An affiliate publication with five distinct link placements, each meant to carry a link_type dimension into GA4 so click behavior could be compared by placement:

link_typeComponentContext
inlineAffLinklink in prose
buttonAffButtonstandalone CTA
quickpickQuickPickstop-of-article featured pick
product-card-primaryProductCardmain CTA in a product card
product-card-secondaryProductCardalternate retailer buttons

All five routes cloak through the same /go/[slug] redirect. AffLink and AffButton were built first. QuickPicks and ProductCard came later, as composed layouts. Then, in a separate session, click tracking was added: a gtag call in AffLink and AffButton.

The symptom

Everything worked. Every placement clicked through, every redirect resolved, and server-side click logs showed traffic from all five. GA4 told a different story:

 
 
affiliate_click — link_type: inline    ✓ firing
affiliate_click — link_type: button    ✓ firing
affiliate_click — link_type: quickpick             (never appears)
affiliate_click — link_type: product-card-primary  (never appears)
affiliate_click — link_type: product-card-secondary (never appears)

No errors. No console warnings. Nothing visibly broken. Three event streams that did not exist.

The diagnosis

QuickPicks and ProductCard were written before tracking existed. At the time, rendering their own <Link href={`/go/${slug}`}> was the natural choice. The cloaking route did all the real work, so there was no reason to route through another component.

Then tracking was added, but only to AffLink and AffButton, because those were "the affiliate link components." The composed components never inherited the new behavior, because they had never depended on the old one.

This is the trap in one sentence: shared infrastructure that is not the only code path is not shared infrastructure. It is one implementation among several, and every new responsibility you give it silently misses the others. The bug did not exist when the composed components were written. It was created retroactively, the moment the shared component learned a new job.

The fix

Delegation, in three moves.

1. The shared component accepts context overrides.

 
 
// components/mdx/AffButton.tsx  (repo-verified)
type AffButtonProps = {
  slug: string
  // Optional: which retailer to link to. Defaults to primary.
  retailer?: RetailerSlug
  // Optional: classify the click for analytics. Defaults to "button".
  linkType?: string
  // Optional: override styling for composed layouts.
  className?: string
  children?: React.ReactNode
}

Worth noting the shape underneath. AffButton is a server component. It resolves the product and retailer from the registry server-side (throwing at build on an unknown slug), then hands a ready-made href to a small client AffiliateLink component that owns the gtag call. When a retailer override is passed, the cloak URL carries it as /go/[slug]?via=retailer, so the server-side click log and the GA4 event agree on which retailer the click chose.

2. A resolver helper so overrides can pick non-primary retailers.

 
 
// lib/affiliates.ts  (repo-verified)
// getRetailerLink(productSlug, retailer) → the matching RetailerLink,
// so AffButton can resolve secondary retailers, not just the primary —
// getPrimaryRetailer(productSlug) remains the no-override default path

3. Every composed component renders through it.

 
 
// QuickPicks (repo-verified) →
//   <AffButton slug={slug} linkType="quickpick" className={…}>
//     Check on {retailerDisplayName} →
//   </AffButton>
// ProductCard primary   (repo-verified) →
//   <AffButton slug={slug} linkType="product-card-primary" … />
// ProductCard secondary  (repo-verified) →
//   <AffButton slug={slug} retailer={retailer.retailer}
//              linkType="product-card-secondary" … />

And at the bottom of the delegation chain, the one client component that owns the event, verbatim:

 
 
// components/mdx/AffiliateLink.tsx  ("use client" — repo-verified)
onClick={() => {
  if (typeof window !== "undefined" && window.gtag) {
    window.gtag("event", "affiliate_click", {
      product_slug: slug,
      retailer,
      link_type: linkType,
      transport_type: "beacon",   // survives the target="_blank" unload
    })
  }
}}

The link also carries rel="sponsored nofollow", the FTC- and Google-correct attribution, set once, here, for every placement. That is the delegation argument from a different angle. Attributes like rel are exactly the kind of responsibility that silently misses bypass paths.

After the refactor, all five link_type values fired, validated by clicking each placement in production with GA4 Realtime and the DevTools Network panel open.

The audit that finds this bug before analytics does

The class of bug generalizes far past affiliate links: auth wrappers, logging middleware, error boundaries, feature flags, anything where a "shared" layer gains responsibilities over time.

When shared infrastructure takes on a new job, grep for the bypass paths:

 
 
# every place that builds the link/behavior WITHOUT the shared component
grep -rn "/go/" components/ --include="*.tsx" | grep -v AffButton | grep -v AffLink

If that search returns anything, those are the placements your new responsibility missed.

The server-side click logs are what made this findable at all. Two independent measurement layers meant one could falsify the other. A single measurement layer cannot tell you it is wrong.


Make the shared component the only way to do the thing. If there are two ways, the shared one is optional, and optional infrastructure is where the next feature does not happen.

FAQ

Why didn't TypeScript catch this? Nothing was type-wrong. Both code paths produced valid <Link> elements to valid routes. The type system verifies what code is, not which code path holds a behavior. This is an architecture invariant, and those are enforced by convention, review, or lint rules, not by the compiler.

Could a lint rule enforce it? Yes. no-restricted-syntax (or a small custom ESLint rule) can ban href values matching /go/ outside the two blessed components. That turns the convention into a build failure, which is the only kind of convention that survives a year of future-you.

Isn't the redirect route the natural place to track instead? Server-side logging there is exactly what the build had, and it is why the clicks were provably real. But the redirect cannot know placement. The link_type dimension only exists client-side, at the element the reader clicked. That is why the event has to live in the component layer.

Does this apply outside analytics? Everywhere. Any cross-cutting concern added to a shared layer after siblings have reimplemented the same behavior: auth checks, rate limits, retry logic, error reporting. The trigger is always the same. The shared layer gained a job after the bypass was written.

Further reading

Keep reading

Newsletter

New posts in your inbox. No spam — unsubscribe anytime.

Occasional emails when we publish something worth your time. Unsubscribe anytime.

More about what you'll get on the newsletter page.

THEME
↑↓ navigate↵ openesc close