Tutorial · 6 steps · ~47 minutes

How to Implement a Loyalty Program in Shopify Hydrogen

A working headless loyalty integration in about 45 minutes: install the app, sign a customer token in the root loader, mount the prebuilt widget, and verify it against four checks. Every code block below is complete enough to paste into a stock Hydrogen skeleton.

Short answer

To add a loyalty program to a Shopify Hydrogen storefront, install a loyalty app that ships a headless SDK, expose its API key as a PUBLIC_ environment variable, and sign a short-lived HMAC token for the logged-in customer inside your root loader. Pass that token to a client component that loads the widget script and calls init(). Mount the component once in the layout, then verify redemption rejects a tampered customer ID with a 403.

What do you need before you start?

  • A Hydrogen storefront running locally (a stock `shopify hydrogen init` skeleton is fine)
  • Customer accounts working, so there is a logged-in session to read
  • A Shopify store you can install apps on
  • Node 18 or newer, for the crypto module used to sign the token

How do you add a loyalty program to Hydrogen, step by step?

  1. STEP 15 min

    Install the app and copy your API key

    Where does a headless store get its loyalty API key?

    Loyalty programs on Shopify are configured in the Shopify Admin, not in your storefront code, even when the storefront is headless. Install Anchor from the Shopify App Store, set up your earning rules and rewards there, then open the app home page and generate a key in the Headless SDK section. The API key (`ak_...`) is public, it identifies your store to the widget and is safe to ship in client-side code.

    The same screen shows the signing secret (`sk_...`) next to the key, once. That one is private. It never leaves your server, and step 3 explains what it does. If you miss it, "Regenerate Key" issues a new pair; the old API key stops working.

    The Headless SDK is on the Pro plan. Use the 14-day trial to build the integration before committing.

  2. STEP 22 min

    Add the environment variables

    Which Hydrogen environment variables does a loyalty widget need?

    Hydrogen reads environment variables from `context.env` inside loaders, and any variable prefixed `PUBLIC_` is also exposed to the browser bundle. The API key belongs in a PUBLIC_ variable because the widget needs it client-side. The signing secret must not carry that prefix.

    .envBASH
    # Public: read by the widget in the browser
    PUBLIC_ANCHOR_API_KEY="ak_xxxxxxxxxxxx"
    
    # Private: server only, never prefixed PUBLIC_
    ANCHOR_SIGNING_SECRET="sk_xxxxxxxxxxxx"

    On Oxygen, set the same two variables in the Hydrogen storefront settings so deployed builds get them.

  3. STEP 315 min

    Add a token route that signs the customer token

    How do you authenticate a logged-in customer to a loyalty API in Hydrogen?

    This is the step that actually matters, and the one most integrations get wrong. The widget runs in the browser, so it cannot be trusted about who the customer is. If your loyalty API accepted a raw customer ID from the client, anyone could edit it in DevTools and redeem someone else’s points. That is an IDOR vulnerability, and it is the default outcome of the naive integration.

    The fix is a short-lived token your server signs. Hydrogen already knows who the customer is through the Customer Account API, so a small resource route reads the customer ID from the session, signs it together with a timestamp using your secret, and returns the token as plain text. The widget calls that route whenever it needs a token. The loyalty API recomputes the same signature and rejects anything that does not match. The client never gets the secret and cannot forge a token for a different customer.

    Oxygen runs on an edge runtime without `node:crypto`, so the signing helper uses Web Crypto, which works on Oxygen, Node and every other runtime.

    app/routes/api.loyalty-token.tsxTSX
    const CUSTOMER_QUERY = `#graphql
      query { customer { id } }
    `;
    
    // HMAC-SHA256 over "customerId.unixSeconds", hex encoded. Web Crypto, so it
    // runs on Oxygen as well as Node.
    async function signCustomerToken(customerId: string, signingSecret: string) {
      const ts = Math.floor(Date.now() / 1000);
      const key = await crypto.subtle.importKey(
        'raw', new TextEncoder().encode(signingSecret),
        {name: 'HMAC', hash: 'SHA-256'}, false, ['sign'],
      );
      const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${customerId}.${ts}`));
      const hex = [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('');
      return `${customerId}.${ts}.${hex}`;
    }
    
    export async function loader({context}: Route.LoaderArgs) {
      const {customerAccount, env} = context;
    
      // Only the server decides who the customer is. A request without a
      // Customer Account session gets no token at all.
      if (!(await customerAccount.isLoggedIn())) {
        return new Response('', {status: 401});
      }
    
      const {data} = await customerAccount.query(CUSTOMER_QUERY);
      const customerId = data.customer.id.replace('gid://shopify/Customer/', '');
      const token = await signCustomerToken(customerId, env.ANCHOR_SIGNING_SECRET);
    
      return new Response(token, {headers: {'Cache-Control': 'no-store'}});
    }

    A token is valid for 60 seconds. The widget keeps one for up to 45 seconds, calls this route for a fresh one before it expires, and retries once with a new token if the loyalty API answers 403, so there is nothing to refresh manually.

  4. STEP 410 min

    Render the widget component

    How do you load the loyalty widget in a React storefront?

    The widget is a script that mounts itself, so the component that hosts it renders nothing of its own. Load the script, call init with the values from the loader, and tear it down on unmount so a client-side navigation does not leave two widgets on the page.

    Guard the effect on the API key being present. A missing environment variable is the most common reason a widget silently fails to appear, and an explicit console error saves an afternoon.

    app/components/LoyaltyWidget.tsxTSX
    import {useEffect} from 'react';
    
    type Props = {
      apiKey: string;
      customerId: string | null;
      customerName: string;
      locale?: string;
    };
    
    export function LoyaltyWidget({
      apiKey,
      customerId,
      customerName,
      locale = 'en',
    }: Props) {
      useEffect(() => {
        if (!apiKey) {
          console.error('LoyaltyWidget: PUBLIC_ANCHOR_API_KEY is not set');
          return;
        }
    
        const script = document.createElement('script');
        script.src = `https://anchorloyalty.app/api/sdk.js?key=${apiKey}`;
        script.async = true;
        script.onload = () => {
          window.AnchorLoyaltySDK?.init({
            apiKey,
            customerId,
            customerName,
            locale,
            // The route from step 3. The widget calls it when it needs a token.
            getCustomerToken: () =>
              fetch('/api/loyalty-token', {credentials: 'include'}).then((r) => r.text()),
          });
        };
        document.body.appendChild(script);
    
        return () => {
          window.AnchorLoyaltySDK?.destroy();
          script.remove();
        };
      }, [apiKey, customerId, customerName, locale]);
    
      return null;
    }
  5. STEP 55 min

    Mount it once in the layout

    Where should the loyalty widget live in a Hydrogen app?

    Mount the widget once, in the layout that wraps every route, not per page. Mounting it inside a route component means it is destroyed and re-created on every navigation, which makes the launcher flicker and re-fetches the balance needlessly.

    The root loader only has to say who is logged in: the numeric customer ID and a first name for the greeting. No token is minted here; the widget fetches that from the route in step 3.

    app/root.tsxTSX
    import {useLoaderData, Outlet} from 'react-router';
    import {LoyaltyWidget} from '~/components/LoyaltyWidget';
    
    const CUSTOMER_QUERY = `#graphql
      query { customer { id firstName } }
    `;
    
    export async function loader({context}: Route.LoaderArgs) {
      const {customerAccount, env} = context;
    
      let loyalty = {customerId: null as string | null, customerName: ''};
    
      if (await customerAccount.isLoggedIn()) {
        const {data} = await customerAccount.query(CUSTOMER_QUERY);
        loyalty = {
          customerId: data.customer.id.replace('gid://shopify/Customer/', ''),
          customerName: data.customer.firstName ?? '',
        };
      }
    
      return {
        loyalty,
        anchorApiKey: env.PUBLIC_ANCHOR_API_KEY,
        // ...the rest of your existing root loader data
      };
    }
    
    export default function App() {
      const {loyalty, anchorApiKey} = useLoaderData<typeof loader>();
    
      return (
        <html lang="en">
          <body>
            <PageLayout>
              <Outlet />
            </PageLayout>
    
            <LoyaltyWidget
              apiKey={anchorApiKey}
              customerId={loyalty.customerId}
              customerName={loyalty.customerName}
            />
          </body>
        </html>
      );
    }
  6. STEP 610 min

    Verify the integration

    How do you know the loyalty integration actually works?

    Five checks, in this order. Each one fails differently, so running them in sequence tells you where the problem is instead of leaving you guessing.

    Verification checklistBASH
    # 1. Logged out: the launcher appears, balance shows a signed-out state.
    #    Fails => script or apiKey problem, check the Network tab for sdk.js.
    
    # 2. Logged in: the balance matches Shopify Admin for that customer.
    #    Fails => customerId is null (the root loader is not seeing the session)
    #    or /api/loyalty-token returns 401 or a 403 shows in the Network tab
    #    (wrong signing secret).
    
    # 3. Tamper test: edit customerId in DevTools, call redeem.
    #    Expect HTTP 403. Anything else means the token is not being verified.
    
    # 4. Stay on a page for two minutes, then redeem. Expect success: the widget
    #    fetched a fresh token from /api/loyalty-token on its own.
    
    # 5. Navigate between routes: exactly one launcher stays on screen.
    #    Two launchers => the component is mounted per route, not in the layout.

    Check 3 is the one worth doing before launch. It is the difference between a working integration and one that lets any logged-in customer drain another customer’s balance.

What does a prebuilt widget save you from building?

The six steps above are short because none of them build loyalty UI. With an API-only loyalty vendor the same project includes every surface below, plus the work of keeping them current as the API changes.

Launcher button and panel, with open and close state
Points balance, earn history, and pending points
Rewards list with redemption flow and error states
VIP tier progress and thresholds
Referral link generation and share sheet
Discount code application at checkout
Loading, empty, and signed-out states for each
Translations for every locale you sell in

Frequently asked questions

How long does it take to add a loyalty program to a Hydrogen storefront?

About 45 minutes with a prebuilt widget: 5 minutes to install and copy keys, 2 to set environment variables, 15 for the route that signs the customer token, 15 to render and mount the component, and 10 to verify. The token step is the only one with real thinking in it. Building the loyalty UI yourself from a JSON API instead is a different project, typically two to four weeks.

Do you need a loyalty app that supports headless, or can any app work?

Most Shopify loyalty apps inject their widget through a theme app extension, which does not exist on a headless storefront, so they simply do not render. Those that advertise headless support usually mean a REST API with no UI, leaving you to build points display, redemption, referrals and tier logic yourself. Check whether the vendor ships an actual SDK package with a mountable widget before assuming headless is covered.

How do you stop customers from redeeming each other’s points in a headless setup?

Never let the browser assert who the customer is. Your server signs a short-lived HMAC token over the customer ID and a timestamp, and the loyalty API recomputes that signature before honouring any redemption. Without this, the customer ID is just a value in client-side code that anyone can change in DevTools. Test it by editing the ID and calling redeem: a correct integration returns 403.

Does the loyalty widget hurt Core Web Vitals on a headless storefront?

It depends entirely on payload size, which varies enormously between vendors: roughly 14 KB to 650 KB of JavaScript across the major Shopify loyalty apps. Load the script asynchronously and after hydration, as in step 4, so it never blocks the initial render. You can measure any vendor yourself in the Chrome DevTools Network panel on a store that already runs their widget.

Does this work with Next.js and Remix as well as Hydrogen?

Yes. Only step 3 is Hydrogen-specific, because it uses the Customer Account API to identify the logged-in customer. On Next.js or Remix, expose the same token route in whatever server code already knows your customer’s identity, and point getCustomerToken at it. Steps 4, 5 and 6 are the same in any React framework.

Where to go next

The full SDK reference covers every init option, the guest-mode and locale-override behaviour, and the endpoint-by-endpoint rules for when a customer token is required. If you are still choosing a vendor, the headless loyalty overview explains what separates an SDK from a bare API, and the evaluation framework gives you a scorecard to run against every option, including a question about whether headless support is a real package or just documentation.

Build it on the free tier first

Anchor is free up to 200 orders a month, so you can wire the integration end to end before paying for anything. The Headless SDK is on the Pro plan, with a 14-day trial.

Install Anchor on Shopify