Skip to content

fix(checkout): forward ad-attribution URL params to checkout#388

Merged
hta218 merged 1 commit into
mainfrom
fix/forward-attribution-params-to-checkout
May 18, 2026
Merged

fix(checkout): forward ad-attribution URL params to checkout#388
hta218 merged 1 commit into
mainfrom
fix/forward-attribution-params-to-checkout

Conversation

@paul-phan
Copy link
Copy Markdown
Member

Summary

Forward ad-attribution URL params (gclid, fbclid, utm_*, …) from the storefront onto the outbound Shopify checkout URL so Shopify's built-in tracking on the checkout subdomain sees consistent last-click identifiers.

The problem

Hydrogen storefronts live on one origin (mystore.com) and Shopify checkout lives on another (checkout.mystore.com or mystore.myshopify.com). When a buyer arrives via a paid ad and the landing URL carries identifiers like ?gclid=..., ?fbclid=..., or ?utm_source=..., those identifiers stay on the storefront's URL only — they do NOT automatically propagate to the checkout subdomain.

Shopify's built-in tracking running on the checkout origin (Customer Events, GA4, Meta Pixel) then sees the checkout-page URL with no attribution params, and reports every paid-ad order as organic / direct. Last-click attribution silently breaks for every paid order — the kind of bug that's invisible in acceptance tests and only surfaces weeks later when ad-platform reports stop matching reality.

Two cart → checkout transition points are affected:

  1. Regular cart: "Continue to Checkout" <a href={cart.checkoutUrl}> link in app/components/cart/cart-summary.tsx.
  2. Buy-now / cart-permalink flow: app/routes/cart/lines.tsx loader for /cart/<variantId>:<qty>?checkout URLs that cart.create() + redirect() straight to checkout.

Both currently hand off the raw cart.checkoutUrl with no attribution forwarding.

The fix

A small, self-contained helper:

// app/utils/checkout-attribution.ts
export function appendForwardedAttribution(
  checkoutUrl: string,
  searchString: string,
): string;

…wired into both transition points. The helper:

  • Forwards gclid, gbraid, wbraid, fbclid, ttclid, msclkid, and the five utm_* params (deliberately scoped to widely-recognised standard params; affiliate / vendor-specific click IDs usually live in first-party cookies scoped to the apex domain and don't need this URL round-trip).
  • First-write-wins — never overwrites params already on the checkout URL.
  • Falls back to the original checkoutUrl unchanged on parse failure so a malformed input never breaks the checkout redirect.

lines.tsx (Buy-now)

Pure server-side — reads request.url's search and forwards before the redirect(). No client step.

cart-summary.tsx (Continue to Checkout link)

Reads window.location.search after hydration via useEffect and swaps the link's href to the enhanced URL. First render uses the raw checkoutUrl so SSR + client first-paint stay consistent (no hydration mismatch warning).

Verification

  1. Visit https://yourstore.com/?utm_source=test&gclid=GCL_TEST_001.
  2. Add an item to cart → "Continue to Checkout".
  3. The outbound link now points to …?…&utm_source=test&gclid=GCL_TEST_001.
  4. Shopify checkout's Customer Events / GA4 / Meta Pixel pick up the params.

For the Buy-now path, use a "Buy now" button on a PDP (any Pilot derivative that wires the showBuyNowButton schema option) and confirm the redirect destination carries the params.

What this does NOT do

  • Doesn't add server-side conversion forwarders (Meta CAPI, GA4 Measurement Protocol, Google Ads Enhanced Conversions). Those need a separate cart-attribute-stash pattern to bridge the cookie-less webhook hop — that's a much bigger contribution. This PR fixes only the checkout-side tracking that Shopify gives you out of the box.
  • Doesn't introduce any new cookie. Pure URL → URL pass-through.
  • Doesn't require opt-in — every Pilot user with paid traffic benefits immediately.

Out of scope / future work

Storefronts capturing affiliate or vendor-specific click IDs (Traffic Junky tj_clickid, Awin awc, Impact Radius irclickid, etc.) usually round-trip them through a first-party attribution cookie scoped to the apex domain — a separate concern from this URL-only pass-through. Users can extend FORWARDED_ATTRIBUTION_KEYS in user-space if needed.

Files

  • app/utils/checkout-attribution.ts (new, ~90 lines incl. JSDoc)
  • ✏️ app/routes/cart/lines.tsx (+10 LOC)
  • ✏️ app/components/cart/cart-summary.tsx (+20 LOC)

Net diff: ~124 insertions, 3 deletions. No new dependencies.

Hydrogen storefronts live on one origin (`mystore.com`) and Shopify
checkout lives on another (`checkout.mystore.com` or
`mystore.myshopify.com`). When a buyer arrives via a paid ad and the
landing URL carries identifiers like `?gclid=...`, `?fbclid=...`, or
`?utm_source=...`, those identifiers stay on the storefront's URL
only — they do NOT automatically propagate to the checkout
subdomain.

Shopify's built-in tracking running on the checkout origin
(Customer Events / GA4 / Meta Pixel) then sees the customer's
checkout-page URL with no attribution params, and reports every
paid-ad order as organic / direct. Last-click attribution silently
breaks for every paid order — the kind of bug that's invisible in
acceptance tests and only surfaces weeks later when ad-platform
reports stop matching reality.

The fix is small and universal: when handing off to the checkout
URL, append the standard set of attribution params (gclid, gbraid,
wbraid, fbclid, ttclid, msclkid, utm_source/medium/campaign/content/
term) from the storefront's current URL onto the outbound checkout
URL. The checkout's own tracking then sees the same identifiers and
ad-platform reports stay accurate.

Existing params already on the checkout URL are never overwritten —
if the caller set something explicitly, that value wins.

Implementation:

  + app/utils/checkout-attribution.ts (new)
    - `FORWARDED_ATTRIBUTION_KEYS` — the canonical set
    - `appendForwardedAttribution(checkoutUrl, searchString)` —
      pure function, safe inputs, returns the original URL
      unchanged on parse failure so a malformed URL never breaks
      checkout

  M app/routes/cart/lines.tsx
    - Buy-now / cart-permalink flow (/cart/<variantId>:<qty>?checkout)
      forwards from `request.url`'s search on the server before
      `redirect()`. Pure SSR, no client-side step needed.

  M app/components/cart/cart-summary.tsx
    - Regular cart's "Continue to Checkout" link reads
      `window.location.search` after hydration via useEffect and
      swaps the link's href to the enhanced URL. First render uses
      the raw checkoutUrl so SSR + client first-paint stay
      consistent (no hydration mismatch).

No new cookies, no new infrastructure, no opt-in flag — every Pilot
user with paid traffic benefits immediately. Affiliate / vendor-
specific click IDs (tj_clickid, awc, irclickid, …) usually live in
a first-party attribution cookie scoped to the apex domain so they
don't need this URL round-trip; the forwarded set is deliberately
limited to widely-recognised standard params. Storefronts capturing
custom URL-only identifiers can extend `FORWARDED_ATTRIBUTION_KEYS`
in user-space.

The pattern complements server-side conversion tracking work but
doesn't require any of it: it helps Shopify's stock checkout-side
tracking, which every storefront has by default.
@paul-phan paul-phan requested a review from hta218 May 16, 2026 06:29
@hta218 hta218 merged commit af890cf into main May 18, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants