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/hydrogen5.19.2 or later,@shopify/hydrogen2025.5 or later, React 18 or 19, andreact-router7.
Step 1: Install the packages
npm i @joy-loyalty/weaverse-hydrogen @joy-loyalty/hydrogenIf 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| Variable | Required | Value |
|---|---|---|
JOY_API_URL | Yes | https://api.joy.so/app/api/v1/headless — keep the /app/api/v1/headless path. |
JOY_SHOP_DOMAIN | Yes | Your myshopify domain, not your custom storefront domain. |
JOY_API_SECRET | For logged-in customers | Joy 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_URL | No | Overrides 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-Controlto responses for logged-in customers. - Full-page caching. Only cache a page when it is a
GETwith a200response, has no personalized Joy data, has no customersessioncookie, its path (after any locale) doesn't start withaccount,api,cart,discount,graphiqlorsubrequest-profiler, and it isn't a Weaverse Studio preview (noweaverseHost,isDesignMode,__revisionIdand similar parameters). Set aVaryheader 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
cartFetcherandcartRevalidate— without them a redeemed coupon is shown as a code but not applied. - Leave
position,accentColorandlauncherLabelunset so the widget branding from Joy admin applies. - Without
<JoyProvider>, the Joy block falls back to thecartin your root loader data, then loads/cartitself.
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
- In Shopify admin > Hydrogen > your storefront > Storefront settings > Environment variables, add
JOY_API_URL,JOY_SHOP_DOMAIN,JOY_API_SECRET(mark it secret) andPUBLIC_CHECKOUT_DOMAINfor both Preview and Production. - Commit without
.envand deploy to Preview first (shopify hydrogen deployor your GitHub integration). - 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. - 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. - Promote to Production.
Something not right? See Troubleshooting.