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.
Anchor to How it worksHow it works
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:
- On a product page, the button links to a cart permalink, like
/cart/{variant_id}:{quantity}. - In the cart, the button links to
/checkout. handleShopifyRoutes()receives the request and redirects the customer to your store's checkout with Shop Pay.
Hydrogen provides the button in the following forms:
| Export | Package | Use |
|---|---|---|
ShopPayButton | @shopify/hydrogen/react or @shopify/hydrogen/vue | A component for React or Vue. |
renderShopPayButton() | @shopify/hydrogen | Returns an HTML string for server templates or raw HTML rendering. |
createShopPayButton() | @shopify/hydrogen | Returns 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.
Anchor to RequirementsRequirements
- Wire request handlers so that
handleShopifyRoutes()handles/checkoutand cart permalinks. Otherwise, passcheckoutUrlto 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
Vanilla JavaScript
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();
};
}React
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}
</>
);
}Vue
<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
ProductVariantIDs, 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 onevariantsarray. - 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 usesaria-disabledandaria-busyinstead, 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
Vanilla JavaScript
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);
}React
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>
);
}Vue
<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.
Set the button's options as props, or pass them as an options object to renderShopPayButton() and createShopPayButton():
| Option | Type | Description |
|---|---|---|
variants | string[] or {id: string, quantity: number}[] | The variants to buy. Omit to check out the current cart. |
disabled | boolean | Renders the button without a link. |
width | string | The button's CSS width, like 100%. |
borderRadius | string | The button's CSS border radius, like 8px. |
accessibilityLabel | string | The button's accessible name. Defaults to English. |
nonce | string | A Content Security Policy nonce for the button's inline <style> element. |
channel | 'headless' or 'hydrogen' | Attributes checkout to a specific sales channel. |
checkoutUrl | string | The 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.tsIn 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/checkoutor a cart permalink directly. - The variant IDs are
ProductVariantIDs, notProductIDs. - Quantities are positive integers.
- You pass
checkoutUrlif your storefront doesn't route checkout paths throughhandleShopifyRoutes().