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.com   format;
  • 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.tsx   for the current visitor’s wishlist;
  • app/routes/($locale).shared-wishlist.tsx   for 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 __typename   and id   on Product   and ProductVariant  ;
  • retain the Product.variants.nodes.id   and Product.variants.pageInfo  

    selections 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});
  • cache  defaults to true ;
  • cache: false  forces synchronization.

getContent()  returns a Promise<WishlistContent> . With the default Storefront query, the resolved value has the following structure:

{
  items: [
    {
      // Wishlist Power data, normalized by the SDK
      productId: 'gid://shopify/Product/123',
      variantId: 'gid://shopify/ProductVariant/456', // or null
      handle: 'example-product',
      title: 'Example product',
      featuredMediaUrl: 'https://cdn.shopify.com/example.jpg',
      variantTitle: 'Blue / M', // or null
      variantPrice: 12900, // minor currency unit, or null
      addedAt: '2026-09-11T08:30:00Z',

      // Current product returned by Shopify Storefront API
      storefrontProduct: {
        __typename: 'Product',
        id: 'gid://shopify/Product/123',
        title: 'Example product',
        handle: 'example-product',
        availableForSale: true,
        featuredImage: {
          url: 'https://cdn.shopify.com/example.jpg',
          altText: 'Example product',
          width: 1200,
          height: 1200,
        },
        variants: {
          nodes: [
            {
              id: 'gid://shopify/ProductVariant/456',
              title: 'Blue / M',
              availableForSale: true,
              price: {
                amount: '129.00',
                currencyCode: 'EUR',
              },
              compareAtPrice: null,
              image: {
                url: 'https://cdn.shopify.com/example-blue.jpg',
                altText: 'Example product in blue',
                width: 1200,
                height: 1200,
              },
              selectedOptions: [
                {name: 'Color', value: 'Blue'},
                {name: 'Size', value: 'M'},
              ],
              selected: true,
            },
            {
              id: 'gid://shopify/ProductVariant/457',
              title: 'Black / M',
              availableForSale: false,
              price: {
                amount: '129.00',
                currencyCode: 'EUR',
              },
              compareAtPrice: null,
              image: null,
              selectedOptions: [
                {name: 'Color', value: 'Black'},
                {name: 'Size', value: 'M'},
              ],
              selected: false,
            },
          ],
          pageInfo: {
            hasNextPage: false,
            endCursor: 'cursor',
          },
        },
      },

      // The variant matching `variantId`, or null when none was selected
      storefrontVariant: {
        id: 'gid://shopify/ProductVariant/456',
        title: 'Blue / M',
        availableForSale: true,
        price: {amount: '129.00', currencyCode: 'EUR'},
        selected: true,
      },
    },
  ],
  count: 1,
  customerSharedId: '42', // or null
  sharedWishlistToken: 'signed-token', // or null
  sharingEnabled: true,
  sharedWishlistUrl:
    'https://store.example/shared-wishlist?id=42&token=signed-token', // or null
}

The exact fields inside storefrontProduct  and storefrontVariant  follow the Storefront query configured by the merchant. Extra fields added through storefrontProductQuery  are returned in these objects. Products unavailable to the current Storefront sales channel are removed from items , and count  therefore represents the number of visible items.


👉 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.