Install manually

Add Joy Loyalty to your Weaverse storefront by hand — packages, environment variables, the token route, root loader data, Content Security Policy for Weaverse Studio, the Joy blocks and your cart.

Headless storefronts are available on the Ultimate plan. Prefer to let your coding agent do this? See Install with an AI agent.

Overview

After these steps your Weaverse storefront has the Joy loyalty widget on every page and the Joy — Loyalty Page block in Weaverse Studio. The snippets assume a React Router 7 Weaverse project such as the Pilot theme (app/root.tsx, app/entry.server.tsx, app/weaverse/components.ts). Adapt the file names to your project.

Before you start

  • Joy Loyalty installed on your store, on the Ultimate plan.
  • Settings > Developers > Enable Custom Storefront development turned on in Joy admin.
  • Loyalty Page V2 turned on in Joy admin.
  • Customer Account API login working in your storefront (context.customerAccount).
  • @weaverse/hydrogen 5.19.2 or later, @shopify/hydrogen 2025.5 or later, React 18 or 19, and react-router 7.

Step 1: Install the packages

npm i @joy-loyalty/weaverse-hydrogen @joy-loyalty/hydrogen

If you use pnpm or Yarn PnP and the build can't resolve dompurify, install it explicitly: npm i dompurify.

Step 2: Add environment variables

Add these to your local .env file:

JOY_API_URL=https://api.joy.so/app/api/v1/headless
JOY_SHOP_DOMAIN=your-store.myshopify.com
JOY_API_SECRET=<your headless secret>
# JOY_WIDGET_ASSET_URL=   # leave unset on @joy-loyalty/hydrogen 0.2.1+ (defaults to https://api.joy.so); on 0.2.0 set it to https://api.joy.so
PUBLIC_CHECKOUT_DOMAIN=your-store.myshopify.com
VariableRequiredValue
JOY_API_URLYeshttps://api.joy.so/app/api/v1/headless — keep the /app/api/v1/headless path.
JOY_SHOP_DOMAINYesYour myshopify domain, not your custom storefront domain.
JOY_API_SECRETFor logged-in customersJoy admin > Settings > Manage keys > Headless secret — click Generate headless secret, then copy it. The Secret key on the same page does not work for headless.
JOY_WIDGET_ASSET_URLNoOverrides where the widget code loads from. Leave it unset on @joy-loyalty/hydrogen 0.2.1 or later. On 0.2.0, set it to https://api.joy.so.

Never prefix Joy variables with PUBLIC_. Hydrogen sends PUBLIC_* variables to the browser. Treat JOY_API_SECRET like a password: keep .env out of git and mark the variable as secret in Oxygen. If you click Revoke and regenerate headless secret in Joy admin, the old secret stops working immediately — update JOY_API_SECRET right away.

Extend the Env type in env.d.ts:

interface Env extends HydrogenEnv {
  JOY_API_URL: string;
  JOY_SHOP_DOMAIN: string;
  JOY_API_SECRET: string;
  JOY_WIDGET_ASSET_URL?: string;
}

The Joy block loaders read JOY_API_URL, JOY_SHOP_DOMAIN and JOY_WIDGET_ASSET_URL from weaverse.env. Make sure these variables are passed through to Weaverse — if they are missing, Studio shows "Set the JOY_API_URL and JOY_SHOP_DOMAIN..." in place of the block.

Step 3: Add the token route

This route signs a logged-in customer in to Joy. Create app/routes/api.joy-token.ts:

export {loader} from '@joy-loyalty/weaverse-hydrogen/routes/joy-token';

Guests get a 401 not_logged_in, which is expected. Keep the path /api/joy-token.

Optional — for order programs limited to specific products or collections:

// app/routes/api.joy-place-order-scope.ts
export {loader} from '@joy-loyalty/weaverse-hydrogen/routes/joy-place-order-scope';

Step 4: Load Joy data in the root loader

In app/root.tsx, call getJoyInitialData and return its result under the key joy. The Joy block reads that exact key (and the root cart, as a fallback).

import {getJoyInitialData} from '@joy-loyalty/hydrogen/server';
 
export async function loader(args: LoaderFunctionArgs) {
  const {env, storefront, customerAccount} = args.context;
  // ...your existing data
 
  const joy = await getJoyInitialData({
    storefront,
    customerAccount,
    apiUrl: env.JOY_API_URL ?? '',
    shopDomain: env.JOY_SHOP_DOMAIN ?? '',
    secret: env.JOY_API_SECRET ?? '',               // stays on the server
    locale: storefront.i18n.language.toLowerCase(), // Joy expects lowercase
    request: args.request,
  });
 
  return {
    // ...your existing data
    joyApiUrl: env.JOY_API_URL ?? null,
    joyShopDomain: env.JOY_SHOP_DOMAIN ?? null,
    joyAssetUrl: env.JOY_WIDGET_ASSET_URL ?? null,
    joy,
  };
}

getJoyInitialData never throws. Leave its exposeToken option off — it puts the customer's token into the page HTML.

Step 5: Content Security Policy and caching

In app/entry.server.tsx, start from your Weaverse CSP lists and add the Joy lists to each, so both Weaverse Studio and Joy are allowed. Then mark pages for logged-in customers as private:

import {
  joyCspDirectives,
  JOY_PERSONALIZED_CACHE_CONTROL,
  isJoyInitialDataPersonalized,
} from '@joy-loyalty/hydrogen';
 
const weaverseCsp = getWeaverseCsp(request, context);
const joyCsp = joyCspDirectives({
  apiUrl: context.env.JOY_API_URL,
  assetUrl: context.env.JOY_WIDGET_ASSET_URL,
});
 
const {nonce, header, NonceProvider} = createContentSecurityPolicy({
  ...weaverseCsp,
  scriptSrc: [...weaverseCsp.scriptSrc, ...joyCsp.scriptSrc],
  connectSrc: [...weaverseCsp.connectSrc, ...joyCsp.connectSrc],
  styleSrc: [...weaverseCsp.styleSrc, ...joyCsp.styleSrc],
  imgSrc: [...weaverseCsp.imgSrc, 'https://cdn.shopify.com', ...joyCsp.imgSrc],
  fontSrc: [...weaverseCsp.fontSrc, ...joyCsp.fontSrc],
  shop: {
    checkoutDomain: context.env.PUBLIC_CHECKOUT_DOMAIN || context.env.PUBLIC_STORE_DOMAIN,
    storeDomain: context.env.PUBLIC_STORE_DOMAIN,
  },
});
 
// ...render the app, then before returning the response:
const rootJoy = (reactRouterContext.staticHandlerContext.loaderData?.root as {joy?: unknown})?.joy;
if (isJoyInitialDataPersonalized(rootJoy)) {
  responseHeaders.set('Cache-Control', JOY_PERSONALIZED_CACHE_CONTROL); // private, no-store
}
  • Merge all five lists. If you only add connectSrc, Joy scripts, images and fonts are blocked once your policy is enforced.
  • Personalized pages are never cached. Don't add Oxygen-Cache-Control to responses for logged-in customers.
  • Full-page caching. Only cache a page when it is a GET with a 200 response, has no personalized Joy data, has no customer session cookie, its path (after any locale) doesn't start with account, api, cart, discount, graphiql or subrequest-profiler, and it isn't a Weaverse Studio preview (no weaverseHost, isDesignMode, __revisionId and similar parameters). Set a Vary header when you cache.

Step 6: Register the Joy blocks

In app/weaverse/components.ts:

import {joyComponents} from '@joy-loyalty/weaverse-hydrogen';
 
export const components: HydrogenComponent[] = [
  // ...your existing components
  ...joyComponents,
];

joyComponents contains the Joy — Loyalty Page block. How to use it in Studio: Loyalty Page block.

Step 7: Add the widget and give Joy your cart

In your root layout, render the widget once (outside <main>) and wrap the page content in <JoyProvider> so the Joy block can apply coupons to your cart and open your drawer:

import {JoyProvider, JoyWidget} from '@joy-loyalty/hydrogen';
import {useFetcher, useRevalidator} from 'react-router';
 
const joyCartFetcher = useFetcher();
const joyRevalidator = useRevalidator();
 
<main>
  <JoyProvider cart={cart ?? undefined} onCartUpdated={syncCartStore} onOpenCartDrawer={openCartDrawer}>
    {children}
  </JoyProvider>
</main>
<JoyWidget
  apiUrl={data?.joyApiUrl ?? null}
  shopDomain={data?.joyShopDomain ?? undefined}
  assetUrl={data?.joyAssetUrl ?? undefined}
  initialData={data?.joy ?? null}
  locale={locale.language?.toLowerCase()}
  currency={locale.currency}
  country={locale.country}
  cartFetcher={joyCartFetcher}
  cartRevalidate={joyRevalidator.revalidate}
  cartRoute={localeCartPath}          // e.g. /fr-ca/cart
  onOpenCartDrawer={openCartDrawer}
/>
  • Pass both cartFetcher and cartRevalidate — without them a redeemed coupon is shown as a code but not applied.
  • Leave position, accentColor and launcherLabel unset so the widget branding from Joy admin applies.
  • Without <JoyProvider>, the Joy block falls back to the cart in your root loader data, then loads /cart itself.

Prefer a launcher you place per page in Studio instead of <JoyWidget/>? See the optional launcher block in Loyalty Page block. Don't use both.

Step 8: Deploy and verify

  1. In Shopify admin > Hydrogen > your storefront > Storefront settings > Environment variables, add JOY_API_URL, JOY_SHOP_DOMAIN, JOY_API_SECRET (mark it secret) and PUBLIC_CHECKOUT_DOMAIN for both Preview and Production.
  2. Commit without .env and deploy to Preview first (shopify hydrogen deploy or your GitHub integration).
  3. On the preview URL, check as a guest: one launcher on every page, no CSP errors mentioning joy.so, your Weaverse loyalty page shows the guest sections.
  4. Check as a logged-in customer: points and tier match Joy admin, redeeming applies the coupon to the cart, page responses are private, no-store.
  5. Promote to Production.

Something not right? See Troubleshooting.