diff --git a/.changeset/curly-jeans-sleep.md b/.changeset/curly-jeans-sleep.md
new file mode 100644
index 00000000000..82bea3f9e57
--- /dev/null
+++ b/.changeset/curly-jeans-sleep.md
@@ -0,0 +1,5 @@
+---
+'@clerk/clerk-react': minor
+---
+
+Expose ``, ``, `` from `@clerk/clerk-react/experimental`.
diff --git a/.changeset/dark-coins-shake.md b/.changeset/dark-coins-shake.md
new file mode 100644
index 00000000000..bc850e86fa0
--- /dev/null
+++ b/.changeset/dark-coins-shake.md
@@ -0,0 +1,5 @@
+---
+'@clerk/nextjs': minor
+---
+
+Expose ``, ``, `` from `@clerk/nextjs/experimental`.
diff --git a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap
index a36c2dbcd77..a1a95c46118 100644
--- a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap
+++ b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap
@@ -61,6 +61,9 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = `
"types/create-organization-params.mdx",
"types/element-object-key.mdx",
"types/elements-config.mdx",
+ "types/experimental_checkout-button-props.mdx",
+ "types/experimental_plan-details-button-props.mdx",
+ "types/experimental_subscription-details-button-props.mdx",
"types/get-payment-attempts-params.mdx",
"types/get-payment-sources-params.mdx",
"types/get-plans-params.mdx",
@@ -71,6 +74,7 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = `
"types/initialize-payment-source-params.mdx",
"types/internal_checkout-props.mdx",
"types/internal_plan-details-props.mdx",
+ "types/internal_subscription-details-props.mdx",
"types/jwt-claims.mdx",
"types/jwt-header.mdx",
"types/legacy-redirect-props.mdx",
diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json
index 7ccfc7f0ead..f7a7839b994 100644
--- a/packages/nextjs/package.json
+++ b/packages/nextjs/package.json
@@ -49,6 +49,11 @@
"types": "./dist/types/webhooks.d.ts",
"import": "./dist/esm/webhooks.js",
"require": "./dist/cjs/webhooks.js"
+ },
+ "./experimental": {
+ "types": "./dist/types/experimental.d.ts",
+ "import": "./dist/esm/experimental.js",
+ "require": "./dist/cjs/experimental.js"
}
},
"types": "./dist/types/index.d.ts",
diff --git a/packages/nextjs/src/experimental.ts b/packages/nextjs/src/experimental.ts
new file mode 100644
index 00000000000..b8a389fac7d
--- /dev/null
+++ b/packages/nextjs/src/experimental.ts
@@ -0,0 +1,8 @@
+'use client';
+
+export { CheckoutButton, PlanDetailsButton, SubscriptionDetailsButton } from '@clerk/clerk-react/experimental';
+export type {
+ __experimental_CheckoutButtonProps as CheckoutButtonProps,
+ __experimental_SubscriptionDetailsButtonProps as SubscriptionDetailsButtonProps,
+ __experimental_PlanDetailsButtonProps as PlanDetailsButtonProps,
+} from '@clerk/types';
diff --git a/packages/react/package.json b/packages/react/package.json
index 71bb8d8f20f..ea64b82e029 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -53,13 +53,24 @@
"default": "./dist/errors.js"
}
},
+ "./experimental": {
+ "import": {
+ "types": "./dist/experimental.d.mts",
+ "default": "./dist/experimental.mjs"
+ },
+ "require": {
+ "types": "./dist/experimental.d.ts",
+ "default": "./dist/experimental.js"
+ }
+ },
"./package.json": "./package.json"
},
"main": "./dist/index.js",
"files": [
"dist",
"internal",
- "errors"
+ "errors",
+ "experimental"
],
"scripts": {
"build": "tsup",
diff --git a/packages/react/src/components/CheckoutButton.tsx b/packages/react/src/components/CheckoutButton.tsx
new file mode 100644
index 00000000000..27ae4d04060
--- /dev/null
+++ b/packages/react/src/components/CheckoutButton.tsx
@@ -0,0 +1,98 @@
+import type { __experimental_CheckoutButtonProps } from '@clerk/types';
+import React from 'react';
+
+import { useAuth } from '../hooks';
+import type { WithClerkProp } from '../types';
+import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../utils';
+import { withClerk } from './withClerk';
+
+/**
+ * @experimental A button component that opens the Clerk Checkout drawer when clicked. This component must be rendered
+ * inside a `` component to ensure the user is authenticated.
+ *
+ * @example
+ * ```tsx
+ * import { SignedIn } from '@clerk/clerk-react';
+ * import { CheckoutButton } from '@clerk/clerk-react/experimental';
+ *
+ * // Basic usage with default "Checkout" text
+ * function BasicCheckout() {
+ * return (
+ *
+ *
+ *
+ * );
+ * }
+ *
+ * // Custom button with organization subscription
+ * function OrganizationCheckout() {
+ * return (
+ *
+ * console.log('Subscription completed!')}
+ * >
+ *
+ *
+ *
+ * );
+ * }
+ * ```
+ *
+ * @throws {Error} When rendered outside of a `` component
+ * @throws {Error} When `subscriberType="org"` is used without an active organization context
+ */
+export const CheckoutButton = withClerk(
+ ({ clerk, children, ...props }: WithClerkProp>) => {
+ const {
+ planId,
+ planPeriod,
+ subscriberType,
+ onSubscriptionComplete,
+ newSubscriptionRedirectUrl,
+ checkoutProps,
+ ...rest
+ } = props;
+
+ const { userId, orgId } = useAuth();
+
+ if (userId === null) {
+ throw new Error('Ensure that `` is rendered inside a `` component.');
+ }
+
+ if (orgId === null && subscriberType === 'org') {
+ throw new Error('Wrap `` with a check for an active organization.');
+ }
+
+ children = normalizeWithDefaultValue(children, 'Checkout');
+ const child = assertSingleChild(children)('CheckoutButton');
+
+ const clickHandler = () => {
+ if (!clerk) {
+ return;
+ }
+
+ return clerk.__internal_openCheckout({
+ planId,
+ planPeriod,
+ subscriberType,
+ onSubscriptionComplete,
+ newSubscriptionRedirectUrl,
+ ...checkoutProps,
+ });
+ };
+
+ const wrappedChildClickHandler: React.MouseEventHandler = async e => {
+ if (child && typeof child === 'object' && 'props' in child) {
+ await safeExecute(child.props.onClick)(e);
+ }
+ return clickHandler();
+ };
+
+ const childProps = { ...rest, onClick: wrappedChildClickHandler };
+ return React.cloneElement(child as React.ReactElement, childProps);
+ },
+ { component: 'CheckoutButton', renderWhileLoading: true },
+);
diff --git a/packages/react/src/components/PlanDetailsButton.tsx b/packages/react/src/components/PlanDetailsButton.tsx
new file mode 100644
index 00000000000..a4d80f06f03
--- /dev/null
+++ b/packages/react/src/components/PlanDetailsButton.tsx
@@ -0,0 +1,66 @@
+import type { __experimental_PlanDetailsButtonProps } from '@clerk/types';
+import React from 'react';
+
+import type { WithClerkProp } from '../types';
+import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../utils';
+import { withClerk } from './withClerk';
+
+/**
+ * @experimental A button component that opens the Clerk Plan Details drawer when clicked. This component is part of
+ * Clerk's Billing feature which is available under a public beta.
+ *
+ * @example
+ * ```tsx
+ * import { SignedIn } from '@clerk/clerk-react';
+ * import { PlanDetailsButton } from '@clerk/clerk-react/experimental';
+ *
+ * // Basic usage with default "Plan details" text
+ * function BasicPlanDetails() {
+ * return (
+ *
+ * );
+ * }
+ *
+ * // Custom button with custom text
+ * function CustomPlanDetails() {
+ * return (
+ *
+ *
+ *
+ * );
+ * }
+ * ```
+ *
+ * @see https://clerk.com/docs/billing/overview
+ */
+export const PlanDetailsButton = withClerk(
+ ({ clerk, children, ...props }: WithClerkProp>) => {
+ const { plan, planId, initialPlanPeriod, planDetailsProps, ...rest } = props;
+ children = normalizeWithDefaultValue(children, 'Plan details');
+ const child = assertSingleChild(children)('PlanDetailsButton');
+
+ const clickHandler = () => {
+ if (!clerk) {
+ return;
+ }
+
+ return clerk.__internal_openPlanDetails({
+ plan,
+ planId,
+ initialPlanPeriod,
+ ...planDetailsProps,
+ });
+ };
+
+ const wrappedChildClickHandler: React.MouseEventHandler = async e => {
+ if (child && typeof child === 'object' && 'props' in child) {
+ await safeExecute(child.props.onClick)(e);
+ }
+ return clickHandler();
+ };
+
+ const childProps = { ...rest, onClick: wrappedChildClickHandler };
+ return React.cloneElement(child as React.ReactElement, childProps);
+ },
+ { component: 'PlanDetailsButton', renderWhileLoading: true },
+);
diff --git a/packages/react/src/components/SubscriptionDetailsButton.tsx b/packages/react/src/components/SubscriptionDetailsButton.tsx
new file mode 100644
index 00000000000..3a8e585b637
--- /dev/null
+++ b/packages/react/src/components/SubscriptionDetailsButton.tsx
@@ -0,0 +1,88 @@
+import type { __experimental_SubscriptionDetailsButtonProps } from '@clerk/types';
+import React from 'react';
+
+import { useAuth } from '../hooks';
+import type { WithClerkProp } from '../types';
+import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../utils';
+import { withClerk } from './withClerk';
+
+/**
+ * @experimental A button component that opens the Clerk Subscription Details drawer when clicked. This component must be rendered
+ * inside a `` component to ensure the user is authenticated.
+ *
+ * @example
+ * ```tsx
+ * import { SignedIn } from '@clerk/clerk-react';
+ * import { SubscriptionDetailsButton } from '@clerk/clerk-react/experimental';
+ *
+ * // Basic usage with default "Subscription details" text
+ * function BasicSubscriptionDetails() {
+ * return (
+ *
+ * );
+ * }
+ *
+ * // Custom button with organization subscription
+ * function OrganizationSubscriptionDetails() {
+ * return (
+ * console.log('Subscription canceled')}
+ * >
+ *
+ *
+ * );
+ * }
+ * ```
+ *
+ * @throws {Error} When rendered outside of a `` component
+ * @throws {Error} When `for="org"` is used without an active organization context
+ *
+ * @see https://clerk.com/docs/billing/overview
+ */
+export const SubscriptionDetailsButton = withClerk(
+ ({
+ clerk,
+ children,
+ ...props
+ }: WithClerkProp>) => {
+ const { for: forProp, subscriptionDetailsProps, onSubscriptionCancel, ...rest } = props;
+ children = normalizeWithDefaultValue(children, 'Subscription details');
+ const child = assertSingleChild(children)('SubscriptionDetailsButton');
+
+ const { userId, orgId } = useAuth();
+
+ if (userId === null) {
+ throw new Error('Ensure that `` is rendered inside a `` component.');
+ }
+
+ if (orgId === null && forProp === 'org') {
+ throw new Error(
+ 'Wrap `` with a check for an active organization.',
+ );
+ }
+
+ const clickHandler = () => {
+ if (!clerk) {
+ return;
+ }
+
+ return clerk.__internal_openSubscriptionDetails({
+ for: forProp,
+ onSubscriptionCancel,
+ ...subscriptionDetailsProps,
+ });
+ };
+
+ const wrappedChildClickHandler: React.MouseEventHandler = async e => {
+ if (child && typeof child === 'object' && 'props' in child) {
+ await safeExecute(child.props.onClick)(e);
+ }
+ return clickHandler();
+ };
+
+ const childProps = { ...rest, onClick: wrappedChildClickHandler };
+ return React.cloneElement(child as React.ReactElement, childProps);
+ },
+ { component: 'SubscriptionDetailsButton', renderWhileLoading: true },
+);
diff --git a/packages/react/src/components/__tests__/CheckoutButton.test.tsx b/packages/react/src/components/__tests__/CheckoutButton.test.tsx
new file mode 100644
index 00000000000..6bfb28b0bc0
--- /dev/null
+++ b/packages/react/src/components/__tests__/CheckoutButton.test.tsx
@@ -0,0 +1,199 @@
+import '@testing-library/jest-dom/vitest';
+
+import type { Theme } from '@clerk/types';
+import { render, screen, waitFor } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import React from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useAuth } from '../../hooks';
+import { CheckoutButton } from '../CheckoutButton';
+
+// Mock the useAuth hook
+vi.mock('../../hooks', () => ({
+ useAuth: vi.fn(),
+}));
+
+const mockOpenCheckout = vi.fn();
+
+const mockClerk = {
+ __internal_openCheckout: mockOpenCheckout,
+};
+
+// Mock the withClerk HOC
+vi.mock('../withClerk', () => {
+ return {
+ withClerk: (Component: any) => (props: any) => {
+ return (
+
+ );
+ },
+ };
+});
+
+describe('CheckoutButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('Authentication Validation', () => {
+ it('throws error when rendered without authenticated user', () => {
+ // Mock useAuth to return null userId
+ (useAuth as any).mockReturnValue({ userId: null, orgId: null });
+
+ // Expect the component to throw an error
+ expect(() => render()).toThrow(
+ 'Ensure that `` is rendered inside a `` component.',
+ );
+ });
+
+ it('throws error when using org subscriber type without active organization', () => {
+ // Mock useAuth to return userId but no orgId
+ (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: null });
+
+ // Expect the component to throw an error when subscriberType is "org"
+ expect(() =>
+ render(
+ ,
+ ),
+ ).toThrow('Wrap `` with a check for an active organization.');
+ });
+
+ it('renders successfully with authenticated user', () => {
+ // Mock useAuth to return valid userId
+ (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: null });
+
+ // Component should render without throwing
+ expect(() => render()).not.toThrow();
+ });
+
+ it('renders successfully with org subscriber type when organization is active', () => {
+ // Mock useAuth to return both userId and orgId
+ (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: 'org_123' });
+
+ // Component should render without throwing
+ expect(() =>
+ render(
+ ,
+ ),
+ ).not.toThrow();
+ });
+ });
+
+ describe('Event Handling', () => {
+ beforeEach(() => {
+ // Set up valid authentication for all event tests
+ (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: null });
+ });
+
+ it('calls clerk.__internal_openCheckout with correct props when clicked', async () => {
+ const props = {
+ planId: 'test_plan',
+ planPeriod: 'month' as const,
+ onSubscriptionComplete: vi.fn(),
+ newSubscriptionRedirectUrl: '/success',
+ checkoutProps: {
+ appearance: {} as Theme,
+ onClose: vi.fn(),
+ },
+ };
+
+ render();
+
+ await userEvent.click(screen.getByText('Checkout'));
+
+ await waitFor(() => {
+ expect(mockOpenCheckout).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ...props.checkoutProps,
+ planId: props.planId,
+ onSubscriptionComplete: props.onSubscriptionComplete,
+ newSubscriptionRedirectUrl: props.newSubscriptionRedirectUrl,
+ planPeriod: props.planPeriod,
+ }),
+ );
+ });
+ });
+
+ it('executes child onClick handler before opening checkout', async () => {
+ const childOnClick = vi.fn();
+ const props = { planId: 'test_plan' };
+
+ render(
+
+
+ ,
+ );
+
+ await userEvent.click(screen.getByText('Custom Button'));
+
+ await waitFor(() => {
+ expect(childOnClick).toHaveBeenCalled();
+ expect(mockOpenCheckout).toHaveBeenCalledWith(expect.objectContaining(props));
+ });
+ });
+
+ it('uses default "Checkout" text when no children provided', () => {
+ render();
+
+ expect(screen.getByText('Checkout')).toBeInTheDocument();
+ });
+
+ it('renders custom button content when provided', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('Subscribe Now')).toBeInTheDocument();
+ });
+ });
+
+ describe('Props Handling', () => {
+ beforeEach(() => {
+ (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: null });
+ });
+
+ it('passes additional props to child element', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('checkout-btn')).toBeInTheDocument();
+ });
+
+ it('handles portal configuration correctly', async () => {
+ const portalProps = {
+ planId: 'test_plan',
+ checkoutProps: {
+ portalId: 'custom-portal',
+ portalRoot: document.createElement('div'),
+ },
+ };
+
+ render();
+
+ await userEvent.click(screen.getByText('Checkout'));
+ await waitFor(() => {
+ expect(mockOpenCheckout).toHaveBeenCalledWith(
+ expect.objectContaining({ ...portalProps.checkoutProps, planId: portalProps.planId }),
+ );
+ });
+ });
+ });
+});
diff --git a/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx b/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx
new file mode 100644
index 00000000000..45a9ca786a7
--- /dev/null
+++ b/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx
@@ -0,0 +1,172 @@
+import '@testing-library/jest-dom/vitest';
+
+import type { CommercePayerType, CommercePlanResource, Theme } from '@clerk/types';
+import { render, screen, waitFor } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import React from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { PlanDetailsButton } from '../PlanDetailsButton';
+
+const mockOpenPlanDetails = vi.fn();
+
+const mockClerk = {
+ __internal_openPlanDetails: mockOpenPlanDetails,
+};
+
+// Mock the withClerk HOC
+vi.mock('../withClerk', () => {
+ return {
+ withClerk: (Component: any) => (props: any) => {
+ return (
+
+ );
+ },
+ };
+});
+
+const mockPlanResource: CommercePlanResource = {
+ id: 'plan_123',
+ name: 'Test Plan',
+ amount: 1000,
+ amountFormatted: '10.00',
+ annualAmount: 10000,
+ annualAmountFormatted: '100.00',
+ annualMonthlyAmount: 833,
+ annualMonthlyAmountFormatted: '8.33',
+ currencySymbol: '$',
+ description: 'Test Plan Description',
+ hasBaseFee: true,
+ isRecurring: true,
+ currency: 'USD',
+ isDefault: false,
+ forPayerType: 'user' as CommercePayerType,
+ publiclyVisible: true,
+ slug: 'test-plan',
+ avatarUrl: 'https://example.com/avatar.png',
+ features: [],
+ __internal_toSnapshot: vi.fn(),
+ pathRoot: '',
+ reload: vi.fn(),
+};
+
+describe('PlanDetailsButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('Rendering', () => {
+ it('renders with default "Plan details" text when no children provided', () => {
+ render();
+
+ expect(screen.getByText('Plan details')).toBeInTheDocument();
+ });
+
+ it('renders custom button content when provided', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('View Plan')).toBeInTheDocument();
+ });
+
+ it('passes additional props to child element', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('plan-details-btn')).toBeInTheDocument();
+ });
+ });
+
+ describe('Event Handling', () => {
+ it('calls clerk.__internal_openPlanDetails with planId when clicked', async () => {
+ const props = {
+ planId: 'test_plan',
+ initialPlanPeriod: 'month' as const,
+ planDetailsProps: {
+ appearance: {} as Theme,
+ },
+ };
+
+ render();
+
+ await userEvent.click(screen.getByText('Plan details'));
+
+ await waitFor(() => {
+ expect(mockOpenPlanDetails).toHaveBeenCalledWith(
+ expect.objectContaining({ ...props.planDetailsProps, planId: props.planId }),
+ );
+ });
+ });
+
+ it('calls clerk.__internal_openPlanDetails with plan object when clicked', async () => {
+ const props = {
+ plan: mockPlanResource,
+ planDetailsProps: {
+ appearance: {} as Theme,
+ },
+ };
+
+ render();
+
+ await userEvent.click(screen.getByText('Plan details'));
+
+ await waitFor(() => {
+ expect(mockOpenPlanDetails).toHaveBeenCalledWith(
+ expect.objectContaining({ ...props.planDetailsProps, plan: props.plan }),
+ );
+ });
+ });
+
+ it('executes child onClick handler before opening plan details', async () => {
+ const childOnClick = vi.fn();
+ const props = { planId: 'test_plan' };
+
+ render(
+
+
+ ,
+ );
+
+ await userEvent.click(screen.getByText('Custom Button'));
+
+ await waitFor(() => {
+ expect(childOnClick).toHaveBeenCalled();
+ expect(mockOpenPlanDetails).toHaveBeenCalledWith(expect.objectContaining(props));
+ });
+ });
+ });
+
+ describe('Portal Configuration', () => {
+ it('handles portal configuration correctly', async () => {
+ const portalProps = {
+ planId: 'test_plan',
+ planDetailsProps: {
+ portalId: 'custom-portal',
+ portalRoot: document.createElement('div'),
+ },
+ };
+
+ render();
+
+ await userEvent.click(screen.getByText('Plan details'));
+
+ await waitFor(() => {
+ expect(mockOpenPlanDetails).toHaveBeenCalledWith(
+ expect.objectContaining({ ...portalProps.planDetailsProps, planId: portalProps.planId }),
+ );
+ });
+ });
+ });
+});
diff --git a/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx b/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx
new file mode 100644
index 00000000000..2c1c974555c
--- /dev/null
+++ b/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx
@@ -0,0 +1,205 @@
+import '@testing-library/jest-dom/vitest';
+
+import type { Theme } from '@clerk/types';
+import { render, screen, waitFor } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import React from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useAuth } from '../../hooks';
+import { SubscriptionDetailsButton } from '../SubscriptionDetailsButton';
+
+// Mock the useAuth hook
+vi.mock('../../hooks', () => ({
+ useAuth: vi.fn(),
+}));
+
+const mockOpenSubscriptionDetails = vi.fn();
+
+const mockClerk = {
+ __internal_openSubscriptionDetails: mockOpenSubscriptionDetails,
+};
+
+// Mock the withClerk HOC
+vi.mock('../withClerk', () => {
+ return {
+ withClerk: (Component: any) => (props: any) => {
+ return (
+
+ );
+ },
+ };
+});
+
+describe('SubscriptionDetailsButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('Authentication Validation', () => {
+ it('throws error when rendered without authenticated user', () => {
+ // Mock useAuth to return null userId
+ (useAuth as any).mockReturnValue({ userId: null, orgId: null });
+
+ // Expect the component to throw an error
+ expect(() => render()).toThrow(
+ 'Ensure that `` is rendered inside a `` component.',
+ );
+ });
+
+ it('throws error when using org subscriber type without active organization', () => {
+ // Mock useAuth to return userId but no orgId
+ (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: null });
+
+ // Expect the component to throw an error when for="org"
+ expect(() => render()).toThrow(
+ 'Wrap `` with a check for an active organization.',
+ );
+ });
+
+ it('renders successfully with authenticated user', () => {
+ // Mock useAuth to return valid userId
+ (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: null });
+
+ // Component should render without throwing
+ expect(() => render()).not.toThrow();
+ });
+
+ it('renders successfully with org subscriber type when organization is active', () => {
+ // Mock useAuth to return both userId and orgId
+ (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: 'org_123' });
+
+ // Component should render without throwing
+ expect(() => render()).not.toThrow();
+ });
+ });
+
+ describe('Rendering', () => {
+ beforeEach(() => {
+ // Set up valid authentication for all rendering tests
+ (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: null });
+ });
+
+ it('renders with default "Subscription details" text when no children provided', () => {
+ render();
+
+ expect(screen.getByText('Subscription details')).toBeInTheDocument();
+ });
+
+ it('renders custom button content when provided', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('View Subscription')).toBeInTheDocument();
+ });
+
+ it('passes additional props to child element', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('subscription-btn')).toBeInTheDocument();
+ });
+ });
+
+ describe('Event Handling', () => {
+ beforeEach(() => {
+ // Set up valid authentication for all event tests
+ (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: null });
+ });
+
+ it('calls clerk.__internal_openSubscriptionDetails with correct props when clicked', async () => {
+ const onSubscriptionCancel = vi.fn();
+ const props = {
+ for: 'user' as const,
+ onSubscriptionCancel,
+ subscriptionDetailsProps: {
+ appearance: {} as Theme,
+ },
+ };
+
+ render();
+
+ await userEvent.click(screen.getByText('Subscription details'));
+
+ await waitFor(() => {
+ expect(mockOpenSubscriptionDetails).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ...props.subscriptionDetailsProps,
+ for: props.for,
+ onSubscriptionCancel: props.onSubscriptionCancel,
+ }),
+ );
+ });
+ });
+
+ it('executes child onClick handler before opening subscription details', async () => {
+ const childOnClick = vi.fn();
+
+ render(
+
+
+ ,
+ );
+
+ await userEvent.click(screen.getByText('Custom Button'));
+
+ await waitFor(() => {
+ expect(childOnClick).toHaveBeenCalled();
+ expect(mockOpenSubscriptionDetails).toHaveBeenCalled();
+ });
+ });
+
+ it('calls onSubscriptionCancel when provided', async () => {
+ const onSubscriptionCancel = vi.fn();
+
+ render(
+
+
+ ,
+ );
+
+ await userEvent.click(screen.getByText('Cancel Subscription'));
+
+ await waitFor(() => {
+ expect(mockOpenSubscriptionDetails).toHaveBeenCalledWith(expect.objectContaining({ onSubscriptionCancel }));
+ });
+ });
+ });
+
+ describe('Portal Configuration', () => {
+ beforeEach(() => {
+ // Set up valid authentication for portal tests
+ (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: null });
+ });
+
+ it('handles portal configuration correctly', async () => {
+ const portalProps = {
+ subscriptionDetailsProps: {
+ portalId: 'custom-portal',
+ portalRoot: document.createElement('div'),
+ },
+ };
+
+ render();
+
+ await userEvent.click(screen.getByText('Subscription details'));
+
+ await waitFor(() => {
+ expect(mockOpenSubscriptionDetails).toHaveBeenCalledWith(
+ expect.objectContaining({
+ ...portalProps.subscriptionDetailsProps,
+ }),
+ );
+ });
+ });
+ });
+});
diff --git a/packages/react/src/experimental.ts b/packages/react/src/experimental.ts
new file mode 100644
index 00000000000..78da2bc0947
--- /dev/null
+++ b/packages/react/src/experimental.ts
@@ -0,0 +1,3 @@
+export { CheckoutButton } from './components/CheckoutButton';
+export { PlanDetailsButton } from './components/PlanDetailsButton';
+export { SubscriptionDetailsButton } from './components/SubscriptionDetailsButton';
diff --git a/packages/react/src/utils/childrenUtils.tsx b/packages/react/src/utils/childrenUtils.tsx
index bfde8c9be28..0e231b2241c 100644
--- a/packages/react/src/utils/childrenUtils.tsx
+++ b/packages/react/src/utils/childrenUtils.tsx
@@ -5,7 +5,16 @@ import { multipleChildrenInButtonComponent } from '../errors/messages';
export const assertSingleChild =
(children: React.ReactNode) =>
- (name: 'SignInButton' | 'SignUpButton' | 'SignOutButton' | 'SignInWithMetamaskButton') => {
+ (
+ name:
+ | 'SignInButton'
+ | 'SignUpButton'
+ | 'SignOutButton'
+ | 'SignInWithMetamaskButton'
+ | 'CheckoutButton'
+ | 'SubscriptionDetailsButton'
+ | 'PlanDetailsButton',
+ ) => {
try {
return React.Children.only(children);
} catch {
diff --git a/packages/react/tsup.config.ts b/packages/react/tsup.config.ts
index d0290c0e12a..303481e3869 100644
--- a/packages/react/tsup.config.ts
+++ b/packages/react/tsup.config.ts
@@ -12,6 +12,7 @@ export default defineConfig(overrideOptions => {
index: 'src/index.ts',
internal: 'src/internal.ts',
errors: 'src/errors.ts',
+ experimental: 'src/experimental.ts',
},
dts: true,
onSuccess: shouldPublish ? 'pnpm publish:local' : undefined,
diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts
index 3abdb10a664..652e06174de 100644
--- a/packages/types/src/clerk.ts
+++ b/packages/types/src/clerk.ts
@@ -295,7 +295,7 @@ export interface Clerk {
__internal_closeSubscriptionDetails: () => void;
/**
- /** Opens the Clerk UserVerification component in a modal.
+ * Opens the Clerk UserVerification component in a modal.
* @param props Optional user verification configuration parameters.
*/
__internal_openReverification: (props?: __internal_UserVerificationModalProps) => void;
@@ -1832,6 +1832,34 @@ export type __internal_CheckoutProps = {
onClose?: () => void;
};
+/**
+ * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change.
+ * @see https://clerk.com/docs/billing/overview
+ *
+ * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes.
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export type __experimental_CheckoutButtonProps = {
+ planId: string;
+ planPeriod?: CommerceSubscriptionPlanPeriod;
+ subscriberType?: CommercePayerType;
+ onSubscriptionComplete?: () => void;
+ checkoutProps?: {
+ appearance?: CheckoutTheme;
+ portalId?: string;
+ portalRoot?: HTMLElement | null | undefined;
+ onClose?: () => void;
+ };
+ /**
+ * Full URL or path to navigate to after checkout is complete and the user clicks the "Continue" button.
+ * @default undefined
+ */
+ newSubscriptionRedirectUrl?: string;
+};
+
/**
* @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change.
* @see https://clerk.com/docs/billing/overview
@@ -1851,6 +1879,37 @@ export type __internal_PlanDetailsProps = {
portalRoot?: PortalRoot;
};
+/**
+ * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change.
+ * @see https://clerk.com/docs/billing/overview
+ *
+ * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes.
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export type __experimental_PlanDetailsButtonProps = {
+ plan?: CommercePlanResource;
+ planId?: string;
+ initialPlanPeriod?: CommerceSubscriptionPlanPeriod;
+ planDetailsProps?: {
+ appearance?: PlanDetailTheme;
+ portalId?: string;
+ portalRoot?: PortalRoot;
+ };
+};
+
+/**
+ * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change.
+ * @see https://clerk.com/docs/billing/overview
+ *
+ * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes.
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
export type __internal_SubscriptionDetailsProps = {
/**
* The subscriber type to display the subscription details for.
@@ -1864,6 +1923,31 @@ export type __internal_SubscriptionDetailsProps = {
portalRoot?: PortalRoot;
};
+/**
+ * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change.
+ * @see https://clerk.com/docs/billing/overview
+ *
+ * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes.
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export type __experimental_SubscriptionDetailsButtonProps = {
+ /**
+ * The subscriber type to display the subscription details for.
+ * If `org` is provided, the subscription details will be displayed for the active organization.
+ * @default 'user'
+ */
+ for?: CommercePayerType;
+ onSubscriptionCancel?: () => void;
+ subscriptionDetailsProps?: {
+ appearance?: SubscriptionDetailsTheme;
+ portalId?: string;
+ portalRoot?: PortalRoot;
+ };
+};
+
export type __internal_OAuthConsentProps = {
appearance?: OAuthConsentTheme;
/**