> ## Documentation Index
> Fetch the complete documentation index at: https://juo.io/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a custom subscription storefront

> Add subscription products to the cart with selling plans when you don't use the Juo subscription widget.

The [Juo subscription widget](https://juo.io/docs/adding-juo-subscription-widget.md) handles purchase options, pricing, and cart logic for you. Some stores need their own interface instead: a custom product page, a bundle builder, a quiz, or a headless storefront. This guide explains what any custom implementation has to get right for subscription purchases to reach checkout and become Juo subscriptions.

It is written for developers working on a Shopify theme or a headless storefront. Examples use Liquid and the Ajax Cart API, with Storefront API equivalents where they differ.

## How a subscription reaches checkout

Shopify has no separate "subscription product". A subscription is a regular cart line that carries a **selling plan**: a variant ID, a quantity, and a selling plan ID. When an order with that line is placed, Juo creates the subscription from it.

The objects you work with:

| Object | What it is | In Juo |
| - | - | - |
| Selling plan group | A set of purchase options for one or more products | A subscription plan |
| Selling plan | One purchase option, for example "Delivery every 2 weeks" | A delivery frequency within the plan |
| Selling plan allocation | The price of one variant on one selling plan | Calculated from the plan's discount |

Things that follow from this model:

* **A cart line has exactly one selling plan, or none.** No selling plan means a one-time purchase.
* **Only Juo's selling plans become Juo subscriptions.** Plans created by other apps are handled by those apps.
* **Allocations are per variant.** A variant that is not in any subscription plan has no allocations and cannot be added with a selling plan. Shopify rejects the request.
* **The plan's price applies per unit.** Shopify does not link a selling plan to a quantity. See [Offers that depend on quantity](#offers-that-depend-on-quantity).
* **Plans can be recurring or fixed.** A recurring plan (`recurring_deliveries: true`) creates a subscription contract with a stored payment method. A fixed plan does not. Juo uses hidden fixed plans for local payment methods. See [If you use local payment methods](#if-you-use-local-payment-methods).

> [!NOTE]
>
> Set up subscription plans in Juo and assign products to them before you build the storefront. See [Subscribe and save](https://juo.io/docs/subscribe-and-save.md), or assign products and variants with the [Juo Admin API](https://juo.dev/docs/api-reference/admin/products/assign-plan). A selling plan group holds at most 31 plans. With local payment methods enabled, Juo's hidden fixed plans count toward that limit, so each subscription plan supports up to 15 frequencies.

## 1. Expose selling plan data

Your interface needs, for each variant, the recurring Juo plans it can be bought on and their prices. In a theme, render this once into the product template as JSON.

```liquid theme={null}
{%- liquid
  assign juo_groups = product.selling_plan_groups | where: 'app_id', 'juo-subscriptions'
  assign juo_group_ids = juo_groups | map: 'id'
-%}

<script type="application/json" id="subscription-data-{{ product.id }}">
  {
    "requiresSellingPlan": {{ product.requires_selling_plan | json }},
    "selectedVariantId": {{ product.selected_or_first_available_variant.id | json }},
    "selectedSellingPlanId": {{ product.selected_selling_plan.id | json }},
    "variants": [
      {%- for variant in product.variants -%}
        {
          "id": {{ variant.id | json }},
          "available": {{ variant.available | json }},
          "price": {{ variant.price | json }},
          "sellingPlans": [
            {%- assign first = true -%}
            {%- for allocation in variant.selling_plan_allocations -%}
              {%- if juo_group_ids contains allocation.selling_plan_group_id and allocation.selling_plan.recurring_deliveries -%}
                {%- unless first -%},{%- endunless -%}
                {%- assign first = false -%}
                {
                  "id": {{ allocation.selling_plan.id | json }},
                  "name": {{ allocation.selling_plan.name | json }},
                  "price": {{ allocation.price | json }},
                  "compareAtPrice": {{ allocation.compare_at_price | json }},
                  "perDeliveryPrice": {{ allocation.per_delivery_price | json }}
                }
              {%- endif -%}
            {%- endfor -%}
          ]
        }{%- unless forloop.last -%},{%- endunless -%}
      {%- endfor -%}
    ]
  }
</script>
```

Two filters matter here:

* **`app_id: 'juo-subscriptions'`** keeps plans from other apps out of your interface.
* **`recurring_deliveries`** keeps only recurring plans. When local payment methods are enabled, Juo adds a hidden fixed plan with the same name for every recurring plan. Without this filter, every frequency appears twice.

Prices are in the customer's currency, in minor units (for example cents). Format them with your theme's money helpers. `price` is what the customer pays at checkout. If a plan charges for several deliveries at once, `perDeliveryPrice` is the price of one delivery; otherwise the two are equal.

> **Headless storefronts (Storefront API)**
>
> Query the same data from the product:
>
> ```graphql theme={null}
> query ProductSellingPlans($handle: String!) {
>   product(handle: $handle) {
>     requiresSellingPlan
>     sellingPlanGroups(first: 10) {
>       nodes {
>         appName
>         sellingPlans(first: 31) {
>           nodes { id name recurringDeliveries }
>         }
>       }
>     }
>     variants(first: 100) {
>       nodes {
>         id
>         sellingPlanAllocations(first: 31) {
>           nodes {
>             sellingPlan { id name recurringDeliveries }
>             priceAdjustments {
>               price { amount currencyCode }
>               compareAtPrice { amount }
>               perDeliveryPrice { amount }
>             }
>           }
>         }
>       }
>     }
>   }
> }
> ```
>
> Allocations do not say which app created their plan. Collect the IDs of Juo's recurring plans from the groups first, then keep only the allocations on those plans:
>
> ```js theme={null}
> const juoPlanIds = new Set(
>   product.sellingPlanGroups.nodes
>     .filter((group) => group.appName === "Juo Subscriptions")
>     .flatMap((group) => group.sellingPlans.nodes)
>     .filter((plan) => plan.recurringDeliveries)
>     .map((plan) => plan.id)
> );
>
> const plansFor = (variant) =>
>   variant.sellingPlanAllocations.nodes.filter((allocation) =>
>     juoPlanIds.has(allocation.sellingPlan.id)
>   );
> ```
>
> Storefront API IDs are global IDs, for example `gid://shopify/SellingPlan/123`. Paginate `variants` for products with more than 100 variants.

## 2. Build the purchase options

The interface can be anything: radio buttons, a dropdown, a toggle with a frequency picker, or a step in a quiz. Whatever the design, it needs to track two values, the **selected variant** and the **selected selling plan** (or none), and keep them consistent.

```js theme={null}
const data = JSON.parse(
  document.getElementById("subscription-data-PRODUCT_ID").textContent
);

function plansFor(variantId) {
  return data.variants.find((v) => v.id === variantId)?.sellingPlans ?? [];
}

// The URL can carry any selling plan, including other apps' plans and
// fixed plans. Use it only if it is one of this variant's Juo plans.
const initialPlans = plansFor(data.selectedVariantId);
const urlPlanIsValid = initialPlans.some(
  (p) => p.id === data.selectedSellingPlanId
);

const state = {
  variantId: data.selectedVariantId,
  sellingPlanId: urlPlanIsValid
    ? data.selectedSellingPlanId
    : data.requiresSellingPlan
      ? initialPlans[0]?.id ?? null
      : null,
};

function selectVariant(variantId) {
  const previous = plansFor(state.variantId).find(
    (p) => p.id === state.sellingPlanId
  );
  const plans = plansFor(variantId);

  state.variantId = variantId;

  if (state.sellingPlanId != null || data.requiresSellingPlan) {
    // Keep the same frequency if the new variant offers it.
    const match = plans.find((p) => p.name === previous?.name);
    state.sellingPlanId = (match ?? plans[0])?.id ?? null;
  }

  render();
}

function selectSellingPlan(sellingPlanId) {
  state.sellingPlanId = sellingPlanId; // null for one-time purchase
  render();
}
```

Rules to apply in `render()`:

* **Hide the subscription option when the variant has no plans.** Sending a selling plan that does not apply to the variant makes the add-to-cart request fail.
* **Hide the one-time option when `requiresSellingPlan` is true.** The product is set to sell only as a subscription, and Shopify rejects lines without a plan.
* **Show prices from the allocation, not the variant.** The allocation price already includes the plan's discount. Show the saving against `compareAtPrice`.
* **Re-render when the variant changes.** Plans and prices differ per variant.
* **Optional: keep the URL in sync for shareable links.** Shopify reads `?variant=` and `?selling_plan=` on product URLs.

## 3. Add the line to the cart

The selling plan ID has to travel with every add-to-cart request for the product. How you send it depends on how the theme adds to cart.

### Product forms

Shopify's `/cart/add` form endpoint accepts a `selling_plan` field. Set it on whichever form is submitted, since product pages often have more than one (main form, sticky bar, quick add). Listening in the capture phase sets the field before the theme's own submit handlers read the form.

```js theme={null}
document.addEventListener(
  "submit",
  (event) => {
    const form = event.target;
    if (!form.matches('form[action*="/cart/add"]')) return;

    // Only handle forms for this product.
    const variantId = Number(form.elements.id?.value);
    if (!data.variants.some((v) => v.id === variantId)) return;

    let input = form.querySelector('input[name="selling_plan"]');
    if (input == null) {
      input = document.createElement("input");
      input.type = "hidden";
      input.name = "selling_plan";
      form.append(input);
    }

    input.value = state.sellingPlanId ?? "";
    input.disabled = state.sellingPlanId == null; // one-time: send no plan
  },
  true
);
```

If your theme builds the request from its own state instead of the form, pass the selling plan there.

### Ajax Cart API

```js theme={null}
await fetch(window.Shopify.routes.root + "cart/add.js", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    items: [
      {
        id: state.variantId,
        quantity: 1,
        selling_plan: state.sellingPlanId, // omit or null for one-time
      },
    ],
  }),
});
```

### Storefront API

Pass `sellingPlanId` on the cart line:

```graphql theme={null}
mutation AddSubscriptionLine($cartId: ID!, $lines: [CartLineInput!]!) {
  cartLinesAdd(cartId: $cartId, lines: $lines) {
    cart { id }
    userErrors { field message }
  }
}
```

```json theme={null}
{
  "cartId": "gid://shopify/Cart/...",
  "lines": [
    {
      "merchandiseId": "gid://shopify/ProductVariant/111",
      "quantity": 1,
      "sellingPlanId": "gid://shopify/SellingPlan/222"
    }
  ]
}
```

## 4. Show and change the plan in the cart

Cart lines expose the plan as `item.selling_plan_allocation.selling_plan`. Show its name next to the line so customers can see which items will renew and how often.

> [!WARNING]
>
> Keep line item properties that start with an underscore, such as `_selling_plan_id`. Many carts hide them from customers, which is fine, but they must not be removed: Juo reads them when it creates subscriptions.

To change the plan of a line already in the cart, for example from a frequency picker in the cart drawer, use `/cart/change.js`:

```js theme={null}
async function changeLinePlan(item, sellingPlanId) {
  const body = {
    id: item.key,
    quantity: item.quantity, // defaults to 1 if omitted
    selling_plan: sellingPlanId, // null for one-time purchase
  };

  // Passing `properties` replaces the whole object, so copy the existing
  // ones. Clear `_selling_plan_id` so it does not point at the old plan.
  if (item.properties?._selling_plan_id != null) {
    body.properties = { ...item.properties, _selling_plan_id: "" };
  }

  await fetch(window.Shopify.routes.root + "cart/change.js", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
}
```

With local payment methods enabled, the line's plan in the cart may be a hidden fixed plan. See [If you use local payment methods](#if-you-use-local-payment-methods) for how to read the selected frequency.

## If you use local payment methods

When a cart contains a recurring selling plan, Shopify checkout only offers payment methods that can be charged again later, such as cards, Shop Pay, and PayPal. To support local methods like iDEAL, Bancontact, BLIK, and MB WAY for subscriptions, Juo adds a hidden **fixed** selling plan for each recurring plan and switches subscription lines to it in the cart.

How it works: after a line is added with a recurring plan, Juo's app embed changes the line to the fixed plan with the same name and saves the original recurring plan ID in the `_selling_plan_id` line property. When the order is placed, Juo creates the subscription on that recurring plan.

What this means for a custom storefront:

* **Show and add only recurring plans.** Filter out fixed plans as in [step 1](#1-expose-selling-plan-data). Juo handles the switch in the cart.
* **Read the selected frequency from `_selling_plan_id`.** In the cart, `item.selling_plan_allocation.selling_plan` is the fixed plan. Its name matches the recurring plan, so you can display it, but its ID is not in your list of recurring plans.
  ```js theme={null}
  const recurringPlanId =
    Number(item.properties?._selling_plan_id) ||
    item.selling_plan_allocation?.selling_plan.id;
  ```
* **Clear `_selling_plan_id` when you change a line's plan**, as in [step 4](#4-show-and-change-the-plan-in-the-cart). If it still holds the old plan, the line is not switched to a fixed plan, and the subscription can be created on the old frequency.
* **Keep Juo's app embeds enabled.** In Shopify, go to **Online Store → Themes → Customize → App embeds** and turn on **Local Payments Default** and **Local Payments Info**. They appear once local payment methods are enabled in Juo.
* **Route subscription purchases through the cart.** Dynamic checkout buttons ("Buy it now", express wallets) skip the cart, so the switch to a fixed plan never happens. Hide them while a subscription option is selected.

> [!NOTE]
>
> For headless storefronts or other setups without Juo's app embeds, contact Juo support before enabling local payment methods.

## Other common cases

**Quick add and collection pages.** Every place that adds a product to the cart needs the same selling plan logic, or it adds a one-time line. For products that require a selling plan, a quick add without a plan fails.

**Upsell and cross-sell widgets.** Third-party apps that add products to the cart usually add one-time lines. Check whether they support selling plans before offering subscription products through them.

**Several subscription products in one cart.** Each line carries its own plan. By default, Juo combines lines that renew on the same schedule into one subscription.

**Bundles.** [Shopify Bundles](https://shopify.dev/docs/apps/bundles) cannot use selling plans. To sell a bundle as a subscription, model it as a variant or use [Build-a-box](https://juo.io/docs/build-a-box.md).

**Free gifts and add-ons.** A gift added as a one-time line ships with the first order only. To include it in every delivery, add it with the same selling plan. To give free products or other benefits on selected orders, see [Extras](https://juo.io/docs/extras.md).

### Offers that depend on quantity

Some offers tie the quantity to the plan, for example "1 unit every month" against "3 units every 3 months". Because a selling plan's price applies per unit at any quantity, Shopify does not support this directly. Two options:

* **Variants per pack size.** Create a variant for each size and add each to the plans it is sold on. Each variant gets its own prices and frequencies, and a quantity change in the cart cannot put a line on the wrong price.
* **A selling plan per combination.** Create plans such as "Monthly · 1 unit" and "Every 3 months · 3 units", and add quantity and plan together. Lock the quantity of these lines in the cart, or change the plan when the quantity changes.

For "the more you buy, the more you save" pricing, use [Tiered discounts](https://juo.io/docs/tiered-discounts.md) on a standard plan instead.

## Testing checklist

Before launch, place test orders that cover:

* One-time purchase of a product that also has subscription options.
* Subscription purchase for each frequency, and after switching variants.
* A subscription-only product, from the product page and from quick add.
* Changing quantity and frequency in the cart, and switching a line to one-time.
* A cart with both one-time and subscription lines.
* If local payment methods are enabled: a local method is offered at checkout, and changing the frequency in the cart creates the subscription on the new frequency.

After each subscription order, open the subscription in the Juo Admin Panel and check the product, quantity, frequency, and price.

## Troubleshooting

**No subscription was created.** Check the order in Shopify. The line needs a Juo selling plan, or a fixed plan with a `_selling_plan_id` property. A line with no plan, or with another app's plan, is a one-time purchase for Juo.

**Add to cart fails.** The selling plan does not apply to the variant, or the product requires a selling plan and none was sent.

**The subscription has the wrong frequency.** The line's `_selling_plan_id` still pointed at an earlier plan. Clear it whenever you change a line's plan.

**Local payment methods are not offered at checkout.** Check that Juo's app embeds are on, that the purchase went through the cart, and that the cart lines are on fixed plans.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.