Skip to content

Tier 3 — Catalyst (Next.js) integration

Generated from the canonical integration guide

This page is generated from docs/integration/catalyst.md — the single source of truth shared with the public marketing site. Edit the canonical file, then run npm --prefix tools/integration-docs-derive run derive.

Package: @bc-subscriptions/storefront-catalyst API surface reference: packages/storefront-catalyst/README.md


Who this is for

Developers building on Catalyst — BigCommerce's reference Next.js App Router storefront. If you're on a Catalyst-based storefront, use this tier rather than Tier 2 (headless web component): the storefront-catalyst package ships React 19 components that integrate with Catalyst's existing data fetching patterns and GraphQL layer.


Prerequisites

  • Node.js 18+ with npm or pnpm
  • A Catalyst storefront (Next.js 14+ App Router)
  • React 19 and React DOM 19 (Catalyst ships with React 19 as of the catalyst-core v1 baseline)
  • Deployed apps/api Worker with its base URL
  • BC store hash (available as process.env.BIGCOMMERCE_STORE_HASH in standard Catalyst env setup)
  • At least one subscription plan configured in bc-subscriptions admin

Install

npm install @bc-subscriptions/storefront-catalyst

Peer dependencies (already in a Catalyst app):

npm install react@^19 react-dom@^19

Add environment variables:

# .env.local
NEXT_PUBLIC_SUBS_API_URL=https://subs-api.bigcommerce-testing-7727.workers.dev
NEXT_PUBLIC_BC_STORE_HASH=your_store_hash_here

Minimal working example

Product detail page — SubscriptionWidget

Add the subscription widget to your Catalyst PDP alongside the existing add-to-cart flow:

// app/(default)/products/[slug]/page.tsx
import { SubscriptionWidget, createApiClient } from '@bc-subscriptions/storefront-catalyst';
import { getProduct } from '~/client/queries/product';

const api = createApiClient({
  baseUrl: process.env.NEXT_PUBLIC_SUBS_API_URL!,
});

export default async function ProductPage({
  params,
}: {
  params: { slug: string };
}) {
  const product = await getProduct({ slug: params.slug });

  if (!product) {
    return notFound();
  }

  return (
    <div className="mx-auto max-w-screen-xl">
      {/* existing Catalyst product details */}
      <ProductDetails product={product} />

      {/* subscription widget — renders null if product has no plans */}
      <SubscriptionWidget
        bcProductId={product.entityId}
        bcCustomerId={await getCurrentCustomerId()}
        apiClient={api}
        onSubscribed={(result) => {
          // result.magic_link_url is the post-subscribe redirect
          window.location.href = result.magic_link_url;
        }}
      />
    </div>
  );
}

SubscriptionWidget is a Client Component — it uses useState and useEffect internally. In strict RSC layouts, you may need a thin client-boundary wrapper if your PDP server component can't import client components directly:

// components/SubscriptionWidgetWrapper.tsx
'use client';

import { SubscriptionWidget, createApiClient } from '@bc-subscriptions/storefront-catalyst';

const api = createApiClient({
  baseUrl: process.env.NEXT_PUBLIC_SUBS_API_URL!,
});

interface Props {
  bcProductId: number;
  bcCustomerId: number | null;
}

export function SubscriptionWidgetWrapper({ bcProductId, bcCustomerId }: Props) {
  return (
    <SubscriptionWidget
      bcProductId={bcProductId}
      bcCustomerId={bcCustomerId}
      apiClient={api}
      onSubscribed={(r) => {
        window.location.href = r.magic_link_url;
      }}
    />
  );
}

Subscriber portal — SubscriberPortalApp

The portal renders on /account/subscriptions. Catalyst's account section is server-rendered; the portal must be client-only:

// app/(default)/account/subscriptions/page.tsx
import { SubscriberPortalApp, createApiClient } from '@bc-subscriptions/storefront-catalyst';
import { getSessionCustomerOrRedirect } from '~/auth';

const api = createApiClient({
  baseUrl: process.env.NEXT_PUBLIC_SUBS_API_URL!,
});

export default async function SubscriptionsPage() {
  const session = await getSessionCustomerOrRedirect();

  return (
    <SubscriberPortalApp
      bcCustomerId={session.customer.entityId}
      apiClient={api}
    />
  );
}

Cart line item — SubscriptionLineItem

Add subscription context to cart line items that are subscription purchases:

// components/cart/CartLineItem.tsx
'use client';

import { SubscriptionLineItem } from '@bc-subscriptions/storefront-catalyst';
import type { CartLineItem as BCLineItem } from '~/client/queries/cart';

export function CartLineItem({ item }: { item: BCLineItem }) {
  const isSubscription = item.productOptions?.some(
    (o) => o.name === 'Subscription'
  );

  return (
    <div>
      {/* existing cart item rendering */}
      <ExistingCartItemUI item={item} />

      {isSubscription && (
        <SubscriptionLineItem
          lineItemId={item.entityId}
          className="mt-2 text-sm text-gray-600"
        />
      )}
    </div>
  );
}

API client

The API client is a stateless fetch wrapper. Instantiate it once per module (or per request in server components) and pass it as a prop:

import { createApiClient } from '@bc-subscriptions/storefront-catalyst';

const api = createApiClient({
  baseUrl: 'https://subs-api.bigcommerce-testing-7727.workers.dev',
  authToken: null, // set after magic-link verify
});

// Plans
const plans = await api.getPlans({ bcProductId: 12345 });

// Portal auth
await api.requestMagicLink({ email: 'shopper@example.com' });
// verifyMagicLink authenticates the client in place — subsequent calls are authed.
const session = await api.verifyMagicLink('token-from-email');
// Persist for restoration across reloads (sessionStorage, never localStorage):
sessionStorage.setItem('subs_token', session.token);

// Subscriptions
const sub = await api.getSubscription('sub_abc123');
// getSubscriptions() takes no args — the authToken scopes results to the signed-in customer.
const { subscriptions } = await api.getSubscriptions();

The client is safe to instantiate at module scope for client components. For server components, prefer creating it per-request to avoid cross-request state leakage (particularly authToken).


Configuration

Theming

Components use minimal inline styles by default. Override via the className prop using Tailwind or your CSS system:

<SubscriptionWidget
  bcProductId={product.entityId}
  bcCustomerId={customerId}
  apiClient={api}
  className="rounded-lg border border-gray-200 p-4 shadow-sm"
  onSubscribed={handleSubscribed}
/>

Internal class names follow a subs-* prefix — target them in your global CSS if you need deeper overrides:

/* globals.css */
.subs-plan-card {
  @apply rounded-lg border border-gray-200;
}

.subs-subscribe-button {
  @apply bg-blue-600 text-white hover:bg-blue-700;
}

Auth callbacks

After the magic-link flow completes, the widget calls onSubscribed with the result. Typical patterns:

// Redirect to portal
onSubscribed={(r) => router.push(r.magic_link_url)}

// Show success toast, stay on page
onSubscribed={(r) => {
  toast.success('Subscription created!');
  setSubscriptionId(r.subscription_id);
}}

SSR / RSC notes

  • SubscriptionWidget, SubscriberPortalApp, and SubscriptionLineItem are all Client Components ('use client' directive). They cannot be imported directly into async Server Components without a client boundary wrapper.
  • The createApiClient function is safe to call in both Server and Client contexts.
  • On the server, prefer passing the API base URL from process.env (not NEXT_PUBLIC_*) — it keeps the URL server-only. Use NEXT_PUBLIC_SUBS_API_URL only when the client component needs it without a server prop.
  • Magic-link tokens are JWTs signed by the API Worker. Do not persist them in localStorage — use sessionStorage or in-memory state to limit exposure.

Common pitfalls

1. "Cannot import Server Component into Client Component"

Cause: Attempting to import SubscriptionWidget directly into a server component that itself isn't wrapped in 'use client'. Fix: Create a 'use client' wrapper component that imports from @bc-subscriptions/storefront-catalyst and use the wrapper in your server component.

2. Hydration mismatch on bcCustomerId

Cause: getCurrentCustomerId() returns different values server vs client (e.g., reads a cookie only available server-side). Fix: Pass bcCustomerId as a prop from the server component to the client wrapper, or use null and let the widget trigger the magic-link flow.

3. getPlans returns empty array for a subscribable product

Cause: The product's subscription metafield (subscription.enabled) is not set. Fix: In bc-subscriptions admin, navigate to the product and enable subscriptions. The widget checks this via the API — the metafield is the discovery pointer.

4. NEXT_PUBLIC_SUBS_API_URL undefined at runtime

Cause: Environment variable not set in Vercel/deployment environment. Fix: Ensure NEXT_PUBLIC_SUBS_API_URL is set in your deployment environment's env vars. For local dev, add to .env.local. Vercel: add under Project → Settings → Environment Variables.

5. API client auth token not persisted across navigation

Cause: api.authToken is module-scoped state; Next.js client-side navigation doesn't re-initialize modules. Fix: Persist the token to sessionStorage after verifyMagicLink and restore it on app init:

// After verify
sessionStorage.setItem('subs_token', session.token);
api.authToken = session.token;

// On app init
const stored = sessionStorage.getItem('subs_token');
if (stored) api.authToken = stored;

Next steps