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-catalystAPI 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/apiWorker with its base URL - BC store hash (available as
process.env.BIGCOMMERCE_STORE_HASHin standard Catalyst env setup) - At least one subscription plan configured in bc-subscriptions admin
Install¶
Peer dependencies (already in a Catalyst app):
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, andSubscriptionLineItemare all Client Components ('use client'directive). They cannot be imported directly into async Server Components without a client boundary wrapper.- The
createApiClientfunction is safe to call in both Server and Client contexts. - On the server, prefer passing the API base URL from
process.env(notNEXT_PUBLIC_*) — it keeps the URL server-only. UseNEXT_PUBLIC_SUBS_API_URLonly 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— usesessionStorageor 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¶
- SDK reference — full subscriber-sdk API surface documentation
- Headless integration — if you want the web component approach instead
- ADR-0013 — Catalyst-baseline component-shape rationale
- ADR-0005 — three-surface UI architecture