Skip to content

feat(clerk-react, nextjs): Introduce commerce buttons #6365

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jul 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/curly-jeans-sleep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-react': minor
---

Expose `<CheckoutButton/>`, `<SubscriptionDetailsButton/>`, `<PlanDetailsButton/>` from `@clerk/clerk-react/experimental`.
5 changes: 5 additions & 0 deletions .changeset/dark-coins-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/nextjs': minor
---

Expose `<CheckoutButton/>`, `<SubscriptionDetailsButton/>`, `<PlanDetailsButton/>` from `@clerk/nextjs/experimental`.
4 changes: 4 additions & 0 deletions .typedoc/__tests__/__snapshots__/file-structure.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions packages/nextjs/src/experimental.ts
Original file line number Diff line number Diff line change
@@ -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';
13 changes: 12 additions & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
98 changes: 98 additions & 0 deletions packages/react/src/components/CheckoutButton.tsx
Original file line number Diff line number Diff line change
@@ -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 `<SignedIn />` 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 (
* <SignedIn>
* <CheckoutButton planId="plan_123" />
* </SignedIn>
* );
* }
*
* // Custom button with organization subscription
* function OrganizationCheckout() {
* return (
* <SignedIn>
* <CheckoutButton
* planId="plan_123"
* planPeriod="month"
* subscriberType="org"
* onSubscriptionComplete={() => console.log('Subscription completed!')}
* >
* <button className="custom-button">Subscribe Now</button>
* </CheckoutButton>
* </SignedIn>
* );
* }
* ```
*
* @throws {Error} When rendered outside of a `<SignedIn />` component
* @throws {Error} When `subscriberType="org"` is used without an active organization context
*/
export const CheckoutButton = withClerk(
({ clerk, children, ...props }: WithClerkProp<React.PropsWithChildren<__experimental_CheckoutButtonProps>>) => {
const {
planId,
planPeriod,
subscriberType,
onSubscriptionComplete,
newSubscriptionRedirectUrl,
checkoutProps,
...rest
} = props;

const { userId, orgId } = useAuth();

if (userId === null) {
throw new Error('Ensure that `<CheckoutButton />` is rendered inside a `<SignedIn />` component.');
}

if (orgId === null && subscriberType === 'org') {
throw new Error('Wrap `<CheckoutButton for="organization" />` 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<unknown>, childProps);
},
{ component: 'CheckoutButton', renderWhileLoading: true },
);
66 changes: 66 additions & 0 deletions packages/react/src/components/PlanDetailsButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
* <PlanDetailsButton planId="plan_123" />
* );
* }
*
* // Custom button with custom text
* function CustomPlanDetails() {
* return (
* <PlanDetailsButton planId="plan_123">
* <button>View Plan Details</button>
* </PlanDetailsButton>
* );
* }
* ```
*
* @see https://clerk.com/docs/billing/overview
*/
export const PlanDetailsButton = withClerk(
({ clerk, children, ...props }: WithClerkProp<React.PropsWithChildren<__experimental_PlanDetailsButtonProps>>) => {
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<unknown>, childProps);
},
{ component: 'PlanDetailsButton', renderWhileLoading: true },
);
88 changes: 88 additions & 0 deletions packages/react/src/components/SubscriptionDetailsButton.tsx
Original file line number Diff line number Diff line change
@@ -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 `<SignedIn />` 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 (
* <SubscriptionDetailsButton />
* );
* }
*
* // Custom button with organization subscription
* function OrganizationSubscriptionDetails() {
* return (
* <SubscriptionDetailsButton
* for="org"
* onSubscriptionCancel={() => console.log('Subscription canceled')}
* >
* <button>View Organization Subscription</button>
* </SubscriptionDetailsButton>
* );
* }
* ```
*
* @throws {Error} When rendered outside of a `<SignedIn />` 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<React.PropsWithChildren<__experimental_SubscriptionDetailsButtonProps>>) => {
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 `<SubscriptionDetailsButton />` is rendered inside a `<SignedIn />` component.');
}

if (orgId === null && forProp === 'org') {
throw new Error(
'Wrap `<SubscriptionDetailsButton for="organization" />` 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<unknown>, childProps);
},
{ component: 'SubscriptionDetailsButton', renderWhileLoading: true },
);
Loading