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/hydrogen 2025.5 or later, React 18 or 19, and react-router 7.

Step 1: Install the packages

npm i @joy-loyalty/hydrogen @joy-loyalty/sdk
# Weaverse storefronts only:
npm i @joy-loyalty/weaverse-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 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_URLNoOverrides 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 from cdn.shopify.com, so keep it in imgSrc.
  • 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-Control to 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