Install Wishlist Power on a headless Shopify storefront
This guide explains how to integrate Wishlist Power into a headless storefront.
It covers the recommended setup for Shopify Hydrogen, React Router, and Oxygen.
1. Requirements
- a Shopify Hydrogen storefront with Customer Account configured;
- a permanent Shopify domain in the
store.myshopify.comformat; - a Headless API credential provided by the Wishlist Power app;
- Node.js 20 or later.
2. Install the package
npm install @wishlist-power/headless
3. Environment variables
For local development, create or update the local .env file:
PUBLIC_STORE_DOMAIN=your-store.myshopify.com WISHLIST_POWER_API_TOKEN=whp_hl_xxx.secret
You can find the WISHLIST_POWER_API_TOKEN in the settings of your Wishlist Power application.
For production, in the Hydrogen app, add WISHLIST_POWER_API_TOKEN under Hydrogen storefront
→ Environments and variables for every relevant environment (preview and
production).
The standard Hydrogen Customer Account variables must also be configured. The
SDK uses context.customerAccount and never handles the Customer Account token
in the browser.
4. Create the Hydrogen server route
Create app/routes/api.wishlist.ts :
import {
createWishlistRouteHandlers,
} from '@wishlist-power/headless/hydrogen';
const wishlistRoute = createWishlistRouteHandlers();
export const loader = wishlistRoute.loader;
export const action = wishlistRoute.action;
/api/wishlist is the SDK’s private server route. It centralizes reads and
mutations, including shared-wishlist reads. It does not determine the public
storefront URLs. We recommend creating two separate visitor-facing pages:
app/routes/($locale).wishlist.tsxfor the current visitor’s wishlist;app/routes/($locale).shared-wishlist.tsxfor a shared wishlist.
Configure the public sharing path with the sharedWishlistPath prop on
WishlistProvider . For example, sharedWishlistPath="/shared-wishlist"
generates links to the second page while continuing to use /api/wishlist
internally.
The Hydrogen adapter retrieves the authenticated customer, Shopify domain, and
private variables on the server. It also supports Hydrogen/Oxygen proxies when
enforcing same-origin mutation protection.
Storefront product resolution
The default route configuration requires no additional code. Before exposing
wishlist items to React, the SDK sends their Shopify IDs to the same
/api/wishlist route. The Hydrogen adapter uses context.storefront.query()
on the server to retrieve the current products.
The default query retrieves:
- product ID, title, handle, availability, and featured image;
-
all product variants with their ID, title, availability, selected options,
image, price, and compare-at price.
The current Storefront objects are available on each returned item as
storefrontProduct and storefrontVariant . The original Wishlist Power cache
remains separate and is not replaced with these catalog results.
Every node in storefrontProduct.variants.nodes also contains a Boolean
selected property. It is true when its ID matches the variantId stored in
Wishlist Power. If the wishlist entry has no variantId , all variants have
selected: false . This can be used to initialize a variant selector before an
add-to-cart action.
Extend the Storefront query
Pass storefrontProductQuery when creating the existing route handlers. For
example, this adds vendor , productType , and a metafield without creating a
second route:
// app/routes/api.wishlist.ts
import {
createWishlistRouteHandlers,
} from '@wishlist-power/headless/hydrogen';
const wishlistRoute = createWishlistRouteHandlers({
storefrontProductQuery: `#graphql
query WishlistProducts($ids: [ID!]!) {
nodes(ids: $ids) {
__typename
... on Product {
id
title
handle
availableForSale
vendor
productType
featuredImage { url altText width height }
variants(first: 100) {
nodes {
id
title
availableForSale
price { amount currencyCode }
compareAtPrice { amount currencyCode }
image { url altText width height }
selectedOptions { name value }
}
pageInfo { hasNextPage endCursor }
}
metafield(namespace: "custom", key: "subtitle") {
value
type
}
}
... on ProductVariant {
id
title
availableForSale
price { amount currencyCode }
image { url altText width height }
product { id }
}
}
}
`,
});
export const loader = wishlistRoute.loader;
export const action = wishlistRoute.action;
The custom query must:
- accept an
$ids: [ID!]!variable; - query
nodes(ids: $ids); - retain
__typenameandidonProductandProductVariant; -
retain the
Product.variants.nodes.idandProduct.variants.pageInfoselections if variant lists are required.
Do not accept GraphQL source from browser input. The query must remain static in
the server route. The SDK validates the submitted Shopify IDs, limits each
browser resolution request to 1,000 wishlist entries and deduplicates IDs. The
default adapter keeps each Storefront query bounded and automatically paginates
products with more variants.
5. Provide the current identity in app/root.tsx
In the existing app/root.tsx file, add the identity to the root loader :
import {getHydrogenWishlistIdentity} from '@wishlist-power/headless/hydrogen';
export async function loader(args: Route.LoaderArgs) {
return {
wishlistIdentity: await getHydrogenWishlistIdentity(args.context),
// Keep the other data already returned by the root loader here.
};
}
Then install the provider once around the storefront:
import {Outlet, useRouteLoaderData} from 'react-router';
import {WishlistProvider} from '@wishlist-power/headless/react';
export default function App() {
const data = useRouteLoaderData<typeof loader>('root');
if (!data) return <Outlet />;
return (
<WishlistProvider
identity={data.wishlistIdentity}
sharedWishlistPath="/shared-wishlist"
>
<Outlet />
</WishlistProvider>
);
}
When a guest becomes a customer, the SDK detects that the cached identity has
changed. It calls the backend once, merges missing guest products, and stores
the customer cache. Subsequent reads use this new cache.
6. Add and remove button
The package provides WishlistProvider and useWishlist without imposing any
visual components. The components below are ready-to-copy examples that can be
adapted to the storefront design.
import {useWishlist} from '@wishlist-power/headless/react';
import type {WishlistItemInput} from '@wishlist-power/headless';
export function WishlistButton({item}: {item: WishlistItemInput}) {
const {add, remove, contains, loading, error} = useWishlist();
const isAdded = contains(item);
async function toggle() {
try {
if (isAdded) {
await remove(item);
} else {
await add(item);
}
} catch {
// `error` contains the normalized WishlistError.
}
}
return (
<>
<button type="button" disabled={loading} onClick={() => void toggle()}>
{isAdded ? 'Remove from wishlist' : 'Add to wishlist'}
</button>
{error && <p role="alert">{error.message}</p>}
</>
);
}
Example product passed to the component:
<WishlistButton
item={{
productId: product.id,
title: product.title,
handle: product.handle,
featuredMediaUrl: product.featuredImage?.url,
variantId: selectedVariant?.id ?? null,
variantTitle: selectedVariant?.title ?? null,
variantPrice: selectedVariant
? Math.round(Number(selectedVariant.price.amount) * 100)
: null,
}}
/>
productId is required. variantId and all other product information are
optional. IDs can be numeric or Shopify GIDs. variantPrice uses the minor
currency unit: 12900 represents EUR 129.00.
7. Navigation count
import {NavLink} from 'react-router';
import {useWishlist} from '@wishlist-power/headless/react';
export function WishlistCount() {
const {count, initialized} = useWishlist();
return (
<NavLink to="/wishlist">
Wishlist ({initialized ? count : 'loading…'})
</NavLink>
);
}
count updates automatically after add() or remove() .
8. Private wishlist page
import {Link} from 'react-router';
import {useWishlist} from '@wishlist-power/headless/react';
export default function WishlistPage() {
const {items, initialized, remove, sharedWishlistUrl} = useWishlist();
if (!initialized) return <p>Loading…</p>;
return (
<main>
<h1>My wishlist</h1>
{items.map((item, index) => (
<article key={`${item.productId}:${item.variantId ?? ''}:${index}`}>
{item.featuredMediaUrl && (
<img src={item.featuredMediaUrl} alt={item.title ?? ''} />
)}
<h2>
{item.handle ? (
<Link to={`/products/${item.handle}`}>{item.title}</Link>
) : (
item.title
)}
</h2>
{item.variantPrice != null && <p>{item.variantPrice / 100} €</p>}
<button
type="button"
onClick={() =>
void remove({
productId: item.productId,
variantId: item.variantId,
})
}
>
Remove
</button>
</article>
))}
{sharedWishlistUrl && (
<button
type="button"
onClick={() => navigator.clipboard.writeText(sharedWishlistUrl)}
>
Copy sharing link
</button>
)}
</main>
);
}
sharedWishlistPath is configurable. For example, a merchant can use
/wishlist for the private wishlist and /shared-wishlist for shared
wishlists. The SDK then generates sharing links using the second path.
9. Shared wishlist page
Create a separate route, such as app/routes/($locale).shared-wishlist.tsx :
import {useEffect, useState} from 'react';
import {useSearchParams} from 'react-router';
import {useWishlist} from '@wishlist-power/headless/react';
import type {WishlistContent} from '@wishlist-power/headless';
export default function SharedWishlistPage() {
const {getSharedContent} = useWishlist();
const [searchParams] = useSearchParams();
const [content, setContent] = useState<WishlistContent | null>(null);
const sharedId = searchParams.get('id');
const sharedToken = searchParams.get('token');
useEffect(() => {
if (!sharedId || !sharedToken) return;
void getSharedContent({sharedId, sharedToken}).then(setContent);
}, [getSharedContent, sharedId, sharedToken]);
if (!content) return <p>Loading…</p>;
return (
<main>
<h1>Shared wishlist</h1>
{content.items.map((item, index) => (
<article key={`${item.productId}:${item.variantId ?? ''}:${index}`}>
<h2>{item.title}</h2>
</article>
))}
</main>
);
}
10. React hook methods and values
const wishlist = useWishlist();
| API | Description |
|---|---|
getContent(options?) |
Returns cached content or loads it from the backend. |
getSharedContent(options) |
Loads a shared wishlist without cache and without changing the global private state. |
add(item) |
Adds a product and returns the updated content. |
remove(identity) |
Removes a product or variant and returns the updated content. |
contains(identity) |
Checks whether the product/variant pair is present. |
items |
Normalized array of wishlist products. |
count |
Number of wishlist entries. |
loading |
Indicates that an operation is in progress. |
initialized |
Indicates that the initial load has completed. |
error |
Latest WishlistError , or null . |
sharingEnabled |
Indicates whether sharing is enabled for the store. |
sharedWishlistUrl |
Signed sharing URL, or null . |
getContent(options)
await getContent();
await getContent({cache: false});
cachedefaults totrue;cache: falseforces synchronization.
👉 Products that are not returned by Storefront API are omitted from both private
and shared wishlists. This includes products that are not published to the
storefront’s sales channel. The SDK does not remove those entries from Wishlist
Power: if a product is published again, it can appear again on the next load.
getSharedContent(options)
const sharedContent = await getSharedContent({
sharedId: searchParams.get('id'),
sharedToken: searchParams.get('token'),
});
sharedId and sharedToken are required.
getContent() returns the same enriched WishlistContent exposed by the React
hook. Its items contain the current storefrontProduct and
storefrontVariant objects.
add(item)
await add({
productId: 'gid://shopify/Product/123',
variantId: 'gid://shopify/ProductVariant/456',
title: 'Product',
handle: 'product',
featuredMediaUrl: '<https://cdn.shopify.com/product.jpg>',
variantTitle: 'Blue / M',
variantPrice: 12900,
});
remove(identity)
await remove({
productId: 'gid://shopify/Product/123',
variantId: 'gid://shopify/ProductVariant/456',
});
Use the same variantId that was used when adding the item. For an entry with
no selected variant, pass null or omit the property.
11. Errors
All browser errors are converted to WishlistError :
import {WishlistError} from '@wishlist-power/headless';
try {
await add(item);
} catch (error) {
if (error instanceof WishlistError) {
console.error(error.code, error.status, error.details);
}
}
Important error codes:
| Code | Meaning |
|---|---|
WISHLIST_LIMIT_REACHED |
The monthly quota has been reached; additions are blocked. |
HEADLESS_API_DISABLED |
The Headless API is disabled for the store. |
INVALID_SHARED_TOKEN |
The sharing link is missing, incomplete, or invalid. |
CROSS_SITE_REQUEST |
The mutation came from a different origin. |
NETWORK_ERROR |
The storefront route cannot be reached from the browser. |
WISHLIST_POWER_ERROR |
The Wishlist Power backend returned an error. |
The SDK also emits a console.warn for plan or quota limitations while keeping
the error available to the merchant’s interface.