@dtcnative/relay-hydrogen
v0.7.3
Published
Meta Pixel + Conversions API for Shopify Hydrogen storefronts. Browser pixel + server-side dedup. Powered by Relay (DTC Native).
Maintainers
Readme
@dtcnative/relay-hydrogen
Meta Pixel + Conversions API for Shopify Hydrogen storefronts. Browser pixel + server-side dedup. Powered by Relay, the CAPI proxy built for DTC Native members.
npm i @dtcnative/relay-hydrogenWhy
iOS 14, ad blockers, and Safari ITP eat 30–50% of browser-only Meta Pixel events. Relay sends every event twice — once from the browser via fbq(), once from your server via the Conversions API — sharing an event_id so Meta deduplicates. The server signal recovers most of the lost attribution; this package wires both sides for Hydrogen storefronts.
Quick start
1. Wrap your app
// app/root.tsx
import { RelayProvider, useTrackPageView } from "@dtcnative/relay-hydrogen/client";
import { Outlet, useLocation } from "@remix-run/react";
function PageViewTracker() {
useTrackPageView(useLocation().pathname);
return null;
}
export default function App() {
return (
<RelayProvider
config={{ apiKey: process.env.RELAY_API_KEY! }} // rly_…
>
<PageViewTracker />
<Outlet />
</RelayProvider>
);
}2. Track ecommerce events
// app/components/AddToCartButton.tsx
import { useRelay } from "@dtcnative/relay-hydrogen/client";
export function AddToCartButton({ product, variant }) {
const { track } = useRelay();
return (
<button onClick={() => {
track("AddToCart", {
value: variant.price.amount,
currency: variant.price.currencyCode,
content_ids: [variant.id],
content_type: "product",
num_items: 1,
});
// ...your cart-add action
}}>
Add to cart
</button>
);
}3. Server-side Purchase (deduped)
The browser pixel often dies during the Shopify checkout redirect. Belt-and-suspenders: fire the same Purchase from your order webhook with the same event_id. Meta dedupes.
// app/routes/api.shopify-order-webhook.ts
import { relayCapture, eventIdForOrder } from "@dtcnative/relay-hydrogen/server";
export async function action({ request, context }) {
const order = await request.json();
const eventId = await eventIdForOrder(order.id);
await relayCapture({
apiKey: context.env.RELAY_API_KEY,
pixelId: context.env.META_PIXEL_ID,
eventName: "Purchase",
eventId,
eventSourceUrl: `https://${context.env.SHOP_DOMAIN}/orders/${order.id}`,
userData: { em: order.email, ph: order.phone },
customData: {
value: Number(order.total_price),
currency: order.currency,
content_ids: order.line_items.map((li) => String(li.product_id)),
order_id: String(order.id),
},
request,
});
return new Response("ok");
}Features
- Type-safe events — discriminated union so
track('Purchase', { value })is a TS error ifcurrencyis missing. - React API —
<RelayProvider>+useRelay()+useTrackPageView(pathname). - Server helper —
relayCapture()+eventIdForOrder()for browser-server dedup on Purchase events. - Browser + server dedup — same
event_idshared between fbq and CAPI so Meta sees one event, never two. - Outbound click attribution — preserves
fbclid/gclid/ttclid/utm_*across navigation. - Resilient delivery — batched, retried, and queued for tab-close so events arrive even on flaky networks.
- Stable visitor identity — recognizes returning shoppers across cookie clears, devices, and Safari ITP wipes. Browser pixel and server-side CAPI agree on the same visitor automatically.
- First-party auto-routing — when your store has a custom Relay hostname configured (e.g.
data.brand.com), the SDK uses it without any code change. - Consent-aware — call
tracker.setConsent({ marketing: false })and events ship withopt_out: trueso Meta won't use them for attribution or audiences.
API reference
Client — @dtcnative/relay-hydrogen/client
| Export | Notes |
|---|---|
| RelayProvider | React context provider. Place near the root of your app. |
| useRelay() | { track, identify, setConsent, flush } — call from any component. |
| useTrackPageView(pathname) | Auto-fires PageView whenever pathname changes. |
| useRelayAutoTrack(useMatches()) | Browser-side dual-fire for events tagged on loader results by createRelayLoader. |
| useRelayAutoTrackFetchers(useFetchers()) | Browser-side dual-fire for events tagged on action results by createRelayAction and submitted via useFetcher(). |
| Tracker | Low-level class for advanced (non-React) integrations. |
Server — @dtcnative/relay-hydrogen/server
| Export | Notes |
|---|---|
| createRelayLoader(handler, eventFactory?) | Wrap a Remix loader. Server-fires the event via executionContext.waitUntil (non-blocking — does not delay your loader's response). Injects a __relay marker on the data so useRelayAutoTrack can dual-fire client-side. |
| createRelayAction(handler, eventFactory?) | Same as createRelayLoader but for actions. Pair with useRelayAutoTrackFetchers on the client to dual-fire after a fetcher submit. |
| relayCapture(opts) | Low-level POST of a single event. Use directly only from webhook handlers where you need delivery confirmation. Do NOT await relayCapture(...) inside a loader/action — it blocks the render path. Use createRelayLoader / createRelayAction instead. |
| capturePageViewIfNeeded(opts) | Convenience server-side PageView for routes with attribution params (fbclid, gclid, ttclid, _rly). Pass opts.executionContext (or run inside createRelayLoader) for non-blocking dispatch. |
| eventIdForOrder(orderId) | sha-256 hash of relay:purchase:<orderId>. Use the same on browser side to dedup. |
Configuration (RelayConfig)
type RelayConfig = {
/** rly_… API token from your Relay dashboard. */
apiKey: string;
/**
* Optional Meta pixel id(s). When omitted (recommended), the SDK fetches
* the merchant's configured pixels from the Relay worker at boot, fans
* `fbq` out to each, and buffers any track() calls until init resolves.
* Cached in localStorage with revalidate-in-background.
*/
pixelId?: string | string[];
/**
* Worker URL. Defaults to https://worker.trackrelay.app. The SDK
* automatically switches to your store's configured first-party
* hostname when one is set. Override only for self-hosted setups.
*/
endpoint?: string;
/**
* Dual-fire control. When `true` (default), every event fires BOTH
* browser-side via fbq/ttq AND server-side via CAPI through the
* worker, sharing the same `event_id` so Meta/TikTok dedupe.
* When `false`, only the CAPI path fires — useful for storefronts
* that already inject Meta's pixel via another integration and want
* to avoid double-loading fbevents.js.
*/
pixelEnabled?: boolean;
/** Enable the link decorator. Default: true. */
linkDecorator?: boolean;
/** Override the store's test_event_code per browser session. */
testEventCode?: string;
/** Console-log every track() call. Default: false. */
debug?: boolean;
};Performance & blocking behaviour
The SDK is designed so tracking never blocks page rendering.
track(),identify(),setConsent(),buildCartAttribute()are all synchronous from the caller's perspective. They return void / a value immediately; the network round-trip happens in the background.createRelayLoader/createRelayActiondispatch the server-side CAPI POST via Cloudflare'sexecutionContext.waitUntil()so the request runs as a background promise that DOES NOT block the loader/action's response.capturePageViewIfNeededresolves in a microtask — the actual worker round-trip runs viawaitUntil(passopts.executionContextin non-Hydrogen runtimes).
Don't await relayCapture(...) inside a loader/action
relayCapture() is a low-level helper that performs a synchronous HTTP POST. If you await it inside a Remix loader or action, every page navigation will block on the round-trip to the Relay worker (50–200ms+) before your page can render. On product pages with multiple loaders the cost compounds (up to 8× perf regression measured against a client storefront before 0.7.1).
Use the wrappers (createRelayLoader / createRelayAction) for loaders and actions. They handle non-blocking dispatch via waitUntil automatically. Reserve relayCapture() for webhook handlers where you actually need delivery confirmation before responding with 200.
Browser-side batching
Events are coalesced in-memory and POSTed as a single request per batch:
- Flushes at 10 events in the buffer, OR
- 2000 ms after the first unsent event, OR
- On
pagehide/visibilitychange:hidden(viasendBeaconto survive unload).
Single events POST to /v1/events with a flat payload; batches POST to the same endpoint with { batch: [...] }. The worker handles both shapes transparently.
CORS preflight avoidance
Since 0.7.2 the SDK sends events as CORS simple requests (Content-Type: text/plain;charset=UTF-8 + API key in ?k= query param) so the browser skips the OPTIONS preflight on every event. Saves ~150 ms on the first event of every visitor session. The Relay API key is a publishable token (already inlined in your storefront JS) so URL-positioning it adds no security risk.
Get an API key
DTC Native members get unlimited Relay tenants — sign in at dtcnative.com, open the Relay tool, generate an API key.
License
Business Source License 1.1 — see LICENSE and NOTICE.
You're free to use this in your own ecommerce business. The license converts to Apache-2.0 on the Change Date stated in LICENSE.
