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.

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