Skip to main content

Add Shop Pay buttons

A Shop Pay button sends customers straight to Shop Pay checkout, either with a single product or with their whole cart. This guide shows you how to add Shop Pay buttons to product pages and to the cart.


Hydrogen renders the Shop Pay button on your own server as a <hydrogen-shop-pay-button> element. The element uses a declarative shadow root, so page CSS can't override the button's branded styles, and the button works before any JavaScript runs. No external script loads.

The button links to a path on your own storefront:

  1. On a product page, the button links to a cart permalink, like /cart/{variant_id}:{quantity}.
  2. In the cart, the button links to /checkout.
  3. handleShopifyRoutes() receives the request and redirects the customer to your store's checkout with Shop Pay.

Hydrogen provides the button in the following forms:

ExportPackageUse
ShopPayButton@shopify/hydrogen/react or @shopify/hydrogen/vueA component for React or Vue.
renderShopPayButton()@shopify/hydrogenReturns an HTML string for server templates or raw HTML rendering.
createShopPayButton()@shopify/hydrogenReturns a DOM element for imperative UI code.

The examples in this guide show the core helpers in vanilla JavaScript, and the components in React and Vue.


  • Wire request handlers so that handleShopifyRoutes() handles /checkout and cart permalinks. Otherwise, pass checkoutUrl to the button.
  • Build a product form and a cart.
  • Connect a real store. mock.shop doesn't support checkout.

Anchor to Step 1: Add Shop Pay to product pagesStep 1: Add Shop Pay to product pages

On a product page, render the button next to your add-to-cart button. Render it only when the customer has selected a variant, and pass the variant ID with a quantity:

Product page Shop Pay button

lib/product-shop-pay.ts

import {canAddToCart, createShopPayButton, type ProductFormStore} from '@shopify/hydrogen';

import {cartStore} from './cart';
import type {ProductData} from './product-query';

export function mountProductShopPay(
container: HTMLElement,
quantityInput: HTMLInputElement,
product: ProductData,
store: ProductFormStore<ProductData>,
) {
function render() {
const {options, selectedVariant} = store.getState();
if (!selectedVariant) {
container.replaceChildren();
return;
}

const quantity = Math.max(1, Math.floor(Number(quantityInput.value)) || 1);
const pending = cartStore.getState().pending.lines.size > 0;

container.replaceChildren(
createShopPayButton({
variants: [{id: selectedVariant.id, quantity}],
disabled: !canAddToCart(product, options) || pending,
width: '100%',
borderRadius: '8px',
}),
);
}

render();
quantityInput.addEventListener('input', render);
const unsubscribeProduct = store.subscribe(render);
const unsubscribeCart = cartStore.subscribe(render);

return () => {
quantityInput.removeEventListener('input', render);
unsubscribeProduct();
unsubscribeCart();
};
}
import {canAddToCart} from '@shopify/hydrogen';
import {ShopPayButton} from '@shopify/hydrogen/react';
import {useState} from 'react';

import {useProductForm} from '../lib/product';
import type {ProductData} from '../lib/product-query';

export function AddToCart({product}: {product: ProductData}) {
const {options, selectedVariant, formProps, register, pending} =
useProductForm();
const [quantity, setQuantity] = useState(1);
const addable = canAddToCart(product, options);

return (
<>
<form {...formProps()}>
<input type="hidden" {...register('merchandiseId', {})} />
<input
aria-label="Quantity"
{...register('quantity', {value: quantity})}
onChange={(event) => setQuantity(Number(event.target.value))}
/>
<button
{...register('addToCart', {})}
aria-disabled={!addable}
aria-busy={pending}
onClick={(event) => {
if (!addable) event.preventDefault();
}}
>
Add to cart
</button>
</form>

{selectedVariant ? (
<ShopPayButton
variants={[{id: selectedVariant.id, quantity}]}
disabled={!addable || pending}
width="100%"
borderRadius="8px"
/>
) : null}
</>
);
}
<script setup lang="ts">
import {canAddToCart} from '@shopify/hydrogen';
import {ShopPayButton} from '@shopify/hydrogen/vue';
import {computed, ref} from 'vue';

import {useProductForm} from '../lib/product';
import type {ProductData} from '../lib/product-query';

const props = defineProps<{product: ProductData}>();

const form = useProductForm();
const quantity = ref(1);
const addable = computed(() => canAddToCart(props.product, form.options));

function updateQuantity(event: Event) {
const value = Number((event.target as HTMLInputElement).value);
quantity.value = Number.isFinite(value) && value > 0 ? Math.floor(value) : 1;
}
</script>

<template>
<form v-bind="form.formProps()">
<input type="hidden" v-bind="form.register('merchandiseId', {})" />
<input
aria-label="Quantity"
v-bind="form.register('quantity', {value: quantity})"
@input="updateQuantity"
/>
<button
v-bind="form.register('addToCart', {})"
:aria-disabled="!addable"
:aria-busy="form.pending.value"
@click="!addable && $event.preventDefault()"
>
Add to cart
</button>
</form>

<ShopPayButton
v-if="form.selectedVariant"
:variants="[{id: form.selectedVariant.id, quantity}]"
:disabled="!addable || form.pending.value"
width="100%"
border-radius="8px"
/>
</template>

In vanilla JavaScript, the button is a new element each time the selection, quantity, or pending state changes. Pass the product form store from Product pages and variants, and the quantity input of your add-to-cart form.

Follow these rules for product buttons:

  • Pass ProductVariant IDs, either as GIDs or as bare numeric IDs. Product IDs don't work.
  • Use {id, quantity} objects when you know the quantity. Don't mix plain ID strings and objects in one variants array.
  • Keep the Shop Pay button's state in sync with your add-to-cart button. Base both on canAddToCart(), not only on whether a variant is selected. Disable the Shop Pay button while a product or cart update is pending. The add-to-cart button uses aria-disabled and aria-busy instead, so it stays focusable, as described in Product pages and variants.

Anchor to Step 2: Add Shop Pay to the cartStep 2: Add Shop Pay to the cart

On the cart page or in the cart drawer, render the button without variants. Without variants, the button checks out the current cart. Hide or disable it when the cart is empty or a cart update is pending:

Cart Shop Pay button

components/cart-checkout-button.ts

import {createShopPayButton} from '@shopify/hydrogen';

import {cartStore} from '../lib/cart';

export function mountCartCheckoutButton(container: HTMLElement) {
function render() {
const {data, pending} = cartStore.getState();
if (data.totalQuantity === 0 || !data.checkoutUrl) {
container.replaceChildren();
return;
}

container.replaceChildren(
createShopPayButton({
disabled: pending.lines.size > 0 || Boolean(pending.cost),
width: '100%',
borderRadius: '8px',
}),
Object.assign(document.createElement('a'), {
href: data.checkoutUrl,
textContent: 'Checkout',
}),
);
}

render();
return cartStore.subscribe(render);
}
import {ShopPayButton} from '@shopify/hydrogen/react';

import {useCart} from '../lib/cart';

export function CartCheckoutButton() {
const totalQuantity = useCart((state) => state.data.totalQuantity);
const checkoutUrl = useCart((state) => state.data.checkoutUrl);
const pending = useCart(
(state) => state.pending.lines.size > 0 || state.pending.cost,
);

if (totalQuantity === 0 || !checkoutUrl) return null;

return (
<div>
<ShopPayButton disabled={pending} width="100%" borderRadius="8px" />
<a href={checkoutUrl}>Checkout</a>
</div>
);
}
<script setup lang="ts">
import {ShopPayButton} from '@shopify/hydrogen/vue';

import {useCart} from '../lib/cart';

const totalQuantity = useCart((state) => state.data.totalQuantity);
const checkoutUrl = useCart((state) => state.data.checkoutUrl);
const pending = useCart((state) => state.pending.lines.size > 0 || Boolean(state.pending.cost));
</script>

<template>
<div v-if="totalQuantity > 0 && checkoutUrl">
<ShopPayButton :disabled="pending" width="100%" border-radius="8px" />
<a :href="checkoutUrl">Checkout</a>
</div>
</template>

A disabled button renders without an href and with aria-disabled="true", so nothing happens if a customer selects it while an update is pending.


Anchor to Step 3: Configure the buttonStep 3: Configure the button

Set the button's options as props, or pass them as an options object to renderShopPayButton() and createShopPayButton():

OptionTypeDescription
variantsstring[] or {id: string, quantity: number}[]The variants to buy. Omit to check out the current cart.
disabledbooleanRenders the button without a link.
widthstringThe button's CSS width, like 100%.
borderRadiusstringThe button's CSS border radius, like 8px.
accessibilityLabelstringThe button's accessible name. Defaults to English.
noncestringA Content Security Policy nonce for the button's inline <style> element.
channel'headless' or 'hydrogen'Attributes checkout to a specific sales channel.
checkoutUrlstringThe checkout URL to use when handleShopifyRoutes() doesn't handle /checkout and cart permalinks.

The button's shadow root isolates its styles, so the button doesn't support className or style. Use width and borderRadius to size it.

If your storefront isn't in English, then pass a translated accessibilityLabel. Translate the words around the brand name, but don't translate "Shop Pay".

Pass channel only when checkout needs explicit attribution to the sales channel that issued your Storefront API token.

If your Content Security Policy requires nonces for inline <style> elements, then pass nonce. The nonce doesn't cover the style attribute that width and borderRadius set, so a strict policy must also allow inline style attributes.

Anchor to Use the core helpersUse the core helpers

If you render pages with a server template instead of React or Vue, then call renderShopPayButton() on the server. It returns HTML with a declarative shadow root, so the button works before any JavaScript runs:

Server-rendered button

server/product.ts
import {renderShopPayButton} from '@shopify/hydrogen';

const shopPayHtml = renderShopPayButton({
variants: [{id: selectedVariant.id, quantity: 1}],
width: '100%',
});

In browser code, createShopPayButton() returns an element that you can append to the page, as the vanilla JavaScript examples in steps 1 and 2 do. For buttons you create in the browser under a strict Content Security Policy, pass a nonce to createShopPayButton().


Anchor to Step 4: Test checkoutStep 4: Test checkout

Open a product page and select the Shop Pay button, then add an item to the cart and select the cart's Shop Pay button. Both should open Shop Pay checkout. If checkout fails, then check the following:

  • The request reaches handleShopifyRoutes() before your framework router. Test by opening /checkout or a cart permalink directly.
  • The variant IDs are ProductVariant IDs, not Product IDs.
  • Quantities are positive integers.
  • You pass checkoutUrl if your storefront doesn't route checkout paths through handleShopifyRoutes().


Was this page helpful?