Install manually
Set up the shared foundation for Joy Loyalty on Hydrogen — packages, environment variables, the token route, root loader data, Content Security Policy and caching.
Headless storefronts are available on the Ultimate plan. Prefer to let your coding agent do this? See Install with an AI agent.
Overview
Every Joy headless install shares the same foundation. Once it is in place, you add the Loyalty widget, the Loyalty Page or Weaverse blocks on top.
The snippets below assume a React Router 7 Hydrogen project (app/root.tsx, app/entry.server.tsx, app/routes/). 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.
- Customer Account API login working in Hydrogen (
context.customerAccount). @shopify/hydrogen2025.5 or later, React 18 or 19, andreact-router7.
Step 1: Install the packages
npm i @joy-loyalty/hydrogen @joy-loyalty/sdk
# Weaverse storefronts only:
npm i @joy-loyalty/weaverse-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 above it on the same page does not work for headless. Guests work without it, but /api/joy-token then answers 500 missing_env instead of 401 not_logged_in — set it. |
JOY_WIDGET_ASSET_URL | No | Overrides where the widget code loads from. The default is https://api.joy.so from @joy-loyalty/hydrogen 0.2.1. On 0.2.0, set it to https://api.joy.so — the 0.2.0 default doesn't serve the widget. Otherwise leave it unset. |
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 and customers can't sign in until you update JOY_API_SECRET with the new one — update your environment variables right away.
Then 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;
}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/hydrogen/routes/joy-token';It reads the customer session and the three Joy variables, and returns a short-lived token with Cache-Control: private, no-store. Guests get a 401 not_logged_in, which is expected. Keep the path /api/joy-token — if you change it, pass the new path as tokenRoute to the widget and Loyalty Page.
Optional routes — the widget works without them, but some sections need them:
// app/routes/api.joy-products.ts — "Recently viewed" and "Recommended" product sections
export {loader} from '@joy-loyalty/hydrogen/routes/joy-products';// app/routes/api.joy-place-order-scope.ts — order programs limited to specific products or collections
export {loader} from '@joy-loyalty/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 Weaverse block reads that exact key.
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 — if something fails, the widget simply loads its data in the browser instead. It also accepts optional loginUrl, registerUrl and accountUrl (defaults /account/login, /account/register, /account).
Leave the exposeToken option off. It puts the customer's token into the page HTML.
Step 5: Update Content Security Policy and caching
In app/entry.server.tsx, allow the Joy domains in all five directive lists, set the checkout domain, and mark pages for logged-in customers as private:
import {
joyCspDirectives,
JOY_PERSONALIZED_CACHE_CONTROL,
isJoyInitialDataPersonalized,
} from '@joy-loyalty/hydrogen';
const joyCsp = joyCspDirectives({
apiUrl: context.env.JOY_API_URL,
assetUrl: context.env.JOY_WIDGET_ASSET_URL,
});
const {nonce, header, NonceProvider} = createContentSecurityPolicy({
scriptSrc: ["'self'", 'https://cdn.shopify.com', ...joyCsp.scriptSrc],
connectSrc: ["'self'", ...joyCsp.connectSrc],
styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.shopify.com', ...joyCsp.styleSrc],
imgSrc: ["'self'", 'data:', 'https://cdn.shopify.com', ...joyCsp.imgSrc],
fontSrc: ["'self'", ...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 images and fonts are blocked once your policy is enforced. Merchant-uploaded images are served fromcdn.shopify.com, so keep it inimgSrc. - Personalized pages are never cached. The root loader runs on every route, so any page a logged-in customer views carries their data. Don't add
Oxygen-Cache-Controlto those responses. See Deploy and verify for full-page caching rules. - Checkout domain. Hydrogen analytics and consent need
checkoutDomain; falling back to your store domain is fine.
On Weaverse, add your Weaverse CSP lists first and concatenate the Joy lists to each — see Weaverse blocks.
Next steps
- Add the floating launcher: Loyalty widget.
- Add a full rewards page: Loyalty Page or Weaverse blocks.
- Ship it: Deploy and verify.