diff --git a/.changeset/profile-section-components.md b/.changeset/profile-section-components.md
new file mode 100644
index 00000000000..de5db077940
--- /dev/null
+++ b/.changeset/profile-section-components.md
@@ -0,0 +1,5 @@
+---
+'@clerk/ui': minor
+---
+
+Expose composable `UserProfile` and `OrganizationProfile` subcomponents from `@clerk/ui/experimental`
diff --git a/integration/tests/composed-components.test.ts b/integration/tests/composed-components.test.ts
new file mode 100644
index 00000000000..d75b6928e04
--- /dev/null
+++ b/integration/tests/composed-components.test.ts
@@ -0,0 +1,456 @@
+import { type BrowserContext, expect, type Page, test } from '@playwright/test';
+
+import type { Application } from '../models/application';
+import { appConfigs } from '../presets';
+import { PKGLAB } from '../presets/utils';
+import type { FakeOrganization, FakeUser } from '../testUtils';
+import { createTestUtils } from '../testUtils';
+import { stringPhoneNumber } from '../testUtils/phoneUtils';
+
+/**
+ * E2E parity coverage for the experimental composed exports from `@clerk/ui/experimental`.
+ *
+ * The composed API (`UserProfileProvider`, `UserProfileAccountPanel`, `UserProfileEmailSection`,
+ * ...) lets a developer compose the profile UI from flat exports. Its section wrappers render the
+ * exact same underlying components as the standard `` (e.g. `UserProfileEmailSection`
+ * -> `AccountEmails` from `AccountSections`), so the trusted `user-profile.test.ts` flows apply
+ * unchanged. This suite reuses the shared `@clerk/testing` page-object step helpers (`u.po.userProfile.*`)
+ * and the same assertions as `user-profile.test.ts`, so if a composed export diverges from the
+ * standard behavior an existing, trusted assertion fails.
+ *
+ * `user-profile.test.ts` is intentionally left untouched while this API is experimental. The only
+ * differences handled locally here are the composed component's lack of chrome:
+ * - it does not render the `.cl-userProfile-root` wrapper, so the mount wait keys off a rendered
+ * affordance instead;
+ * - it has no Account/Security tabs — the page renders both panels, so security sections are
+ * already present (no `switchToSecurityTab()`).
+ */
+
+// A page composed from the experimental section exports, mirroring the sections the standard
+// shows across its Account and Security tabs.
+const composedUserProfilePage = () => `'use client';
+import {
+ UserProfileProvider,
+ UserProfileAccountPanel,
+ UserProfileProfileSection,
+ UserProfileUsernameSection,
+ UserProfileEmailSection,
+ UserProfilePhoneSection,
+ UserProfileSecurityPanel,
+ UserProfilePasswordSection,
+ UserProfileMfaSection,
+ UserProfileDeleteSection,
+} from '@clerk/ui/experimental';
+
+export default function Page() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}`;
+
+const provider = () => `'use client'
+import { ClerkProvider } from "@clerk/nextjs";
+
+export function Provider({ children }: { children: any }) {
+ return (
+
+ {children}
+
+ )
+}`;
+
+const layout = () => `import './globals.css';
+import { Inter } from 'next/font/google';
+import { Provider } from './provider';
+
+const inter = Inter({ subsets: ['latin'] });
+
+export const metadata = {
+ title: 'Create Next App',
+ description: 'Generated by create next app',
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}`;
+
+test.describe('composed UserProfile exports @generic', () => {
+ test.describe.configure({ mode: 'serial' });
+ let app: Application;
+ let fakeUser: FakeUser;
+
+ test.beforeAll(async () => {
+ app = await appConfigs.next.appRouter
+ .clone()
+ // The composed exports are bundled into the app (they run in the host React tree and read the
+ // loaded clerk instance via useClerk()), so the app depends on @clerk/ui directly.
+ .addDependency('@clerk/ui', PKGLAB)
+ .addFile('src/app/provider.tsx', provider)
+ .addFile('src/app/layout.tsx', layout)
+ .addFile('src/app/composed/user/page.tsx', composedUserProfilePage)
+ .commit();
+ await app.setup();
+ await app.withEnv(appConfigs.envs.withEmailCodes);
+ await app.dev();
+
+ const m = createTestUtils({ app });
+ fakeUser = m.services.users.createFakeUser({
+ withUsername: true,
+ fictionalEmail: true,
+ withPhoneNumber: true,
+ });
+ await m.services.users.createBapiUser({
+ ...fakeUser,
+ username: undefined,
+ phoneNumber: undefined,
+ });
+ });
+
+ test.afterAll(async () => {
+ await fakeUser.deleteIfExists();
+ await app.teardown();
+ });
+
+ // Sign in as `user` and open the composed profile page. The composed provider renders null until
+ // the user + environment are loaded and emits no `.cl-userProfile-root`, so we wait on a rendered
+ // affordance (the profile section's "Update profile" action) rather than the standard root.
+ const signInAndVisitComposedUserProfile = async (page: Page, context: BrowserContext, user: FakeUser) => {
+ const u = createTestUtils({ app, page, context });
+ await u.po.signIn.goTo();
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
+ await u.po.expect.toBeSignedIn();
+ await u.page.goToRelative('/composed/user');
+ await u.page.getByText(/update profile/i).waitFor({ state: 'visible' });
+ return u;
+ };
+
+ // The flows below reuse the shared `u.po.userProfile` step helpers and mirror the assertions in
+ // `user-profile.test.ts` exactly; only navigation is composed-specific.
+
+ test('can update the username', async ({ page, context }) => {
+ const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
+
+ await u.po.userProfile.clickSetUsername();
+ await u.po.userProfile.waitForSectionCardOpened('username');
+ await u.po.userProfile.typeUsername(fakeUser.username);
+ await u.page.getByText(/Save/i).click();
+ await u.po.userProfile.waitForSectionCardClosed('username');
+
+ const username = await u.page.locator('.cl-profileSectionItem__username').innerText();
+ expect(username).toContain(fakeUser.username);
+ });
+
+ test('can update first and last name', async ({ page, context }) => {
+ const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
+
+ await u.po.userProfile.clickToUpdateProfile();
+ await u.po.userProfile.waitForSectionCardOpened('profile');
+ await u.po.userProfile.typeFirstName('John');
+ await u.po.userProfile.typeLastName('Doe');
+ await u.page.getByText(/Save/i).click();
+ await u.po.userProfile.waitForSectionCardClosed('profile');
+
+ const fullName = await u.page.locator('.cl-profileSectionItem__profile').innerText();
+ expect(fullName).toContain('John Doe');
+ });
+
+ test('can add a new email address', async ({ page, context }) => {
+ const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
+
+ await u.po.userProfile.clickAddEmailAddress();
+ await u.po.userProfile.waitForSectionCardOpened('emailAddresses');
+ const newFakeEmail = `new-${fakeUser.email}`;
+ await u.po.userProfile.typeEmailAddress(newFakeEmail);
+ await u.page.getByRole('button', { name: /^add$/i }).click();
+
+ await u.po.userProfile.enterTestOtpCode();
+
+ await expect(
+ u.page.locator('.cl-profileSectionItem__emailAddresses').filter({ hasText: newFakeEmail }),
+ ).toContainText(newFakeEmail);
+ });
+
+ test('can add a new phone number', async ({ page, context }) => {
+ const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
+
+ await u.po.userProfile.clickAddPhoneNumber();
+ await u.po.userProfile.waitForSectionCardOpened('phoneNumbers');
+ await u.po.userProfile.typePhoneNumber(fakeUser.phoneNumber);
+ await u.page.getByRole('button', { name: /^add$/i }).click();
+
+ await u.po.userProfile.enterTestOtpCode();
+
+ const formattedPhoneNumber = stringPhoneNumber(fakeUser.phoneNumber);
+ await expect(u.page.locator('.cl-profileSectionItem__phoneNumbers')).toContainText(formattedPhoneNumber);
+ });
+
+ test('can add mfa authentication with a phone number', async ({ page, context }) => {
+ const u = await signInAndVisitComposedUserProfile(page, context, fakeUser);
+
+ // No Security tab to switch to — the composed security panel is already rendered on the page.
+ await u.page.getByText(/add two-step verification/i).click();
+ await u.page.getByText(/sms code/i).click();
+
+ const formattedPhoneNumber = stringPhoneNumber(fakeUser.phoneNumber);
+ await u.page.getByRole('button', { name: formattedPhoneNumber }).click();
+
+ await u.page.getByText(/sms code verification enabled/i).waitFor({ state: 'visible' });
+ });
+
+ test('can delete the account', async ({ page, context }) => {
+ const m = createTestUtils({ app });
+ const delFakeUser = m.services.users.createFakeUser({
+ withUsername: true,
+ fictionalEmail: true,
+ withPhoneNumber: true,
+ });
+ await m.services.users.createBapiUser({
+ ...delFakeUser,
+ username: undefined,
+ phoneNumber: undefined,
+ });
+
+ const u = await signInAndVisitComposedUserProfile(page, context, delFakeUser);
+
+ // The delete section is rendered directly in the composed security panel (no tab to switch).
+ await u.page.getByRole('button', { name: /delete account/i }).click();
+ await u.page.locator('input[name=deleteConfirmation]').fill('Delete account');
+ await u.page.getByRole('button', { name: /delete account/i }).click();
+
+ await u.po.expect.toBeSignedOut();
+
+ const sessionCookieList = (await u.page.context().cookies()).filter(cookie => cookie.name.startsWith('__session'));
+ expect(sessionCookieList).toHaveLength(0);
+ });
+});
+
+// The composed `OrganizationProfileProvider` renders null until there is an active organization
+// (`useOrganization()`). Each test signs in a user with exactly one membership, and the page
+// activates it via `setActive` before rendering the composed general sections. The section wrappers
+// (`OrganizationProfileProfileSection` / `...LeaveSection` / `...DeleteSection`) render the same
+// components as the standard `` general page.
+const composedOrganizationProfilePage = () => `'use client';
+import { useEffect } from 'react';
+import { useClerk, useOrganization, useOrganizationList } from '@clerk/nextjs';
+import {
+ OrganizationProfileProvider,
+ OrganizationProfileGeneralPanel,
+ OrganizationProfileProfileSection,
+ OrganizationProfileLeaveSection,
+ OrganizationProfileDeleteSection,
+} from '@clerk/ui/experimental';
+
+function EnsureActiveOrganization() {
+ const { setActive } = useClerk();
+ const { organization } = useOrganization();
+ const { isLoaded, userMemberships } = useOrganizationList({ userMemberships: true });
+
+ useEffect(() => {
+ if (!isLoaded || organization) return;
+ const first = userMemberships?.data?.[0]?.organization;
+ if (first && setActive) {
+ void setActive({ organization: first.id });
+ }
+ }, [isLoaded, organization, userMemberships, setActive]);
+
+ return null;
+}
+
+export default function Page() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+}`;
+
+// The composed `OrganizationProfileSecurityPanel` renders the whole standard security page
+// (`OrganizationSecurityPage`) — the SSO overview plus its configuration wizard. Unlike the general
+// tab it has no composable sub-sections, so the panel takes no children. It is the composed
+// counterpart to the security route the standard `` renders.
+const composedOrganizationSecurityPage = () => `'use client';
+import { useEffect } from 'react';
+import { useClerk, useOrganization, useOrganizationList } from '@clerk/nextjs';
+import {
+ OrganizationProfileProvider,
+ OrganizationProfileSecurityPanel,
+} from '@clerk/ui/experimental';
+
+function EnsureActiveOrganization() {
+ const { setActive } = useClerk();
+ const { organization } = useOrganization();
+ const { isLoaded, userMemberships } = useOrganizationList({ userMemberships: true });
+
+ useEffect(() => {
+ if (!isLoaded || organization) return;
+ const first = userMemberships?.data?.[0]?.organization;
+ if (first && setActive) {
+ void setActive({ organization: first.id });
+ }
+ }, [isLoaded, organization, userMemberships, setActive]);
+
+ return null;
+}
+
+export default function Page() {
+ return (
+ <>
+
+
+
+
+ >
+ );
+}`;
+
+test.describe('composed OrganizationProfile exports @generic', () => {
+ test.describe.configure({ mode: 'serial' });
+ let app: Application;
+ let fakeUser: FakeUser;
+ let fakeOrganization: FakeOrganization;
+
+ test.beforeAll(async () => {
+ app = await appConfigs.next.appRouter
+ .clone()
+ .addDependency('@clerk/ui', PKGLAB)
+ .addFile('src/app/provider.tsx', provider)
+ .addFile('src/app/layout.tsx', layout)
+ .addFile('src/app/composed/organization/page.tsx', composedOrganizationProfilePage)
+ .addFile('src/app/composed/organization-security/page.tsx', composedOrganizationSecurityPage)
+ .commit();
+ await app.setup();
+ await app.withEnv(appConfigs.envs.withEmailCodes);
+ await app.dev();
+
+ const m = createTestUtils({ app });
+ fakeUser = m.services.users.createFakeUser({ fictionalEmail: true });
+ const user = await m.services.users.createBapiUser(fakeUser);
+ fakeOrganization = await m.services.users.createFakeOrganization(user.id);
+ });
+
+ test.afterAll(async () => {
+ // The delete test removes its own organization; ignore if this one is already gone.
+ await fakeOrganization.delete().catch(() => {});
+ await fakeUser.deleteIfExists();
+ await app.teardown();
+ });
+
+ // Sign in as `user` and open the composed organization profile page, waiting for the active org's
+ // "Update profile" affordance (the composed provider renders null until an org is active).
+ const signInAndVisitComposedOrganizationProfile = async (page: Page, context: BrowserContext, user: FakeUser) => {
+ const u = createTestUtils({ app, page, context });
+ await u.po.signIn.goTo();
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
+ await u.po.expect.toBeSignedIn();
+ await u.page.goToRelative('/composed/organization');
+ await u.page.getByText(/update profile/i).waitFor({ state: 'visible' });
+ return u;
+ };
+
+ test('renders the composed organization general sections', async ({ page, context }) => {
+ const u = await signInAndVisitComposedOrganizationProfile(page, context, fakeUser);
+
+ // The active organization's name is surfaced in the profile section, and the danger section
+ // (delete/leave) renders for the admin.
+ await expect(u.page.locator('.cl-profileSectionItem__organizationProfile')).toContainText(fakeOrganization.name);
+ await expect(u.page.getByRole('button', { name: /delete organization/i })).toBeVisible();
+ });
+
+ test('can rename the organization', async ({ page, context }) => {
+ const u = await signInAndVisitComposedOrganizationProfile(page, context, fakeUser);
+
+ const newName = `${fakeOrganization.name}-renamed`;
+ await u.page.getByText(/update profile/i).click();
+ const nameInput = u.page.getByLabel('Name', { exact: true });
+ await nameInput.fill(newName);
+ await u.page.getByText(/Save/i).click();
+
+ await expect(u.page.locator('.cl-profileSectionItem__organizationProfile')).toContainText(newName);
+
+ // Assert the mutation actually reached the backend, not just the DOM.
+ const updated = await u.services.clerk.organizations.getOrganization({
+ organizationId: fakeOrganization.organization.id,
+ });
+ expect(updated.name).toBe(newName);
+ });
+
+ test('can delete the organization', async ({ page, context }) => {
+ const m = createTestUtils({ app });
+ const delFakeUser = m.services.users.createFakeUser({ fictionalEmail: true });
+ const delUser = await m.services.users.createBapiUser(delFakeUser);
+ const delOrg = await m.services.users.createFakeOrganization(delUser.id);
+
+ try {
+ const u = await signInAndVisitComposedOrganizationProfile(page, context, delFakeUser);
+
+ await u.page.getByRole('button', { name: /delete organization/i }).click();
+
+ // The confirmation card requires typing the organization name (its placeholder).
+ await expect(u.page.getByText(/are you sure you want to delete this organization/i)).toBeVisible();
+ await u.page.getByPlaceholder(delOrg.name).fill(delOrg.name);
+ await u.page.getByRole('button', { name: /delete organization/i }).click();
+
+ // Unlike the modal component, the composed provider renders null the moment the org is gone
+ // (useOrganization() -> null), so there is no lingering success screen to assert on. The
+ // observable parity outcome is that the deletion reached the backend.
+ await expect
+ .poll(
+ async () => {
+ try {
+ await u.services.clerk.organizations.getOrganization({ organizationId: delOrg.organization.id });
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ { timeout: 15_000 },
+ )
+ .toBe(false);
+ } finally {
+ await delFakeUser.deleteIfExists();
+ }
+ });
+
+ test('renders the composed organization security page', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.po.signIn.goTo();
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+ await u.page.goToRelative('/composed/organization-security');
+
+ // The composed security panel renders the same OrganizationSecurityPage the standard component
+ // shows on its security route: the "Security" page header plus the SSO overview section. This is
+ // the composed counterpart to the security tab (there are no composable sub-sections here).
+ await expect(u.page.getByRole('heading', { name: /^security$/i })).toBeVisible();
+ await expect(u.page.getByText(/^SSO$/)).toBeVisible();
+ await expect(u.page.getByRole('button', { name: /start configuration/i })).toBeVisible();
+ });
+});
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 1a3a78b3f30..ed5467208c3 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -50,6 +50,11 @@
"import": "./dist/themes/experimental.js",
"default": "./dist/themes/experimental.js"
},
+ "./experimental": {
+ "types": "./dist/experimental/index.d.ts",
+ "import": "./dist/experimental/index.js",
+ "default": "./dist/experimental/index.js"
+ },
"./themes/shadcn.css": "./dist/themes/shadcn.css",
"./register": {
"import": {
diff --git a/packages/ui/src/composed/index.ts b/packages/ui/src/composed/index.ts
new file mode 100644
index 00000000000..67c86cc8e56
--- /dev/null
+++ b/packages/ui/src/composed/index.ts
@@ -0,0 +1,2 @@
+export * from './UserProfile';
+export * from './OrganizationProfile';
diff --git a/packages/ui/src/experimental/__tests__/flat-exports.test.ts b/packages/ui/src/experimental/__tests__/flat-exports.test.ts
new file mode 100644
index 00000000000..c0c82746273
--- /dev/null
+++ b/packages/ui/src/experimental/__tests__/flat-exports.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest';
+
+import * as experimental from '../index';
+
+/**
+ * The experimental entry must expose the composed profile API as flat named
+ * exports (not a namespace object like `UserProfile.Account`). Flat names are
+ * what let consumers render these inside a React Server Component tree without
+ * adding their own `'use client'` boundary — each named export of a `'use client'`
+ * module becomes its own client reference, whereas property access on a namespace
+ * object does not.
+ */
+
+const EXPECTED_EXPORTS = [
+ // Providers
+ 'UserProfileProvider',
+ 'OrganizationProfileProvider',
+ // UserProfile panels
+ 'UserProfileAccountPanel',
+ 'UserProfileSecurityPanel',
+ 'UserProfileBillingPanel',
+ 'UserProfileAPIKeysPanel',
+ // UserProfile account sections
+ 'UserProfileProfileSection',
+ 'UserProfileUsernameSection',
+ 'UserProfileEmailSection',
+ 'UserProfilePhoneSection',
+ 'UserProfileConnectedAccountsSection',
+ 'UserProfileEnterpriseAccountsSection',
+ 'UserProfileWeb3Section',
+ // UserProfile security sections
+ 'UserProfilePasswordSection',
+ 'UserProfilePasskeysSection',
+ 'UserProfileMfaSection',
+ 'UserProfileActiveDevicesSection',
+ 'UserProfileDeleteSection',
+ // OrganizationProfile panels
+ 'OrganizationProfileGeneralPanel',
+ 'OrganizationProfileMembersPanel',
+ 'OrganizationProfileBillingPanel',
+ 'OrganizationProfileAPIKeysPanel',
+ 'OrganizationProfileSecurityPanel',
+ // OrganizationProfile general sections
+ 'OrganizationProfileProfileSection',
+ 'OrganizationProfileDomainsSection',
+ 'OrganizationProfileLeaveSection',
+ 'OrganizationProfileDeleteSection',
+] as const;
+
+describe('@clerk/ui/experimental flat exports', () => {
+ for (const name of EXPECTED_EXPORTS) {
+ it(`exports ${name} as a component`, () => {
+ expect(typeof (experimental as Record)[name]).toBe('function');
+ });
+ }
+
+ it('does not export the compound namespace objects', () => {
+ expect((experimental as Record).UserProfile).toBeUndefined();
+ expect((experimental as Record).OrganizationProfile).toBeUndefined();
+ });
+});
diff --git a/packages/ui/src/experimental/index.ts b/packages/ui/src/experimental/index.ts
new file mode 100644
index 00000000000..7be81eb1f6a
--- /dev/null
+++ b/packages/ui/src/experimental/index.ts
@@ -0,0 +1,13 @@
+// Public entrypoint for the composed profile API (`@clerk/ui/experimental`).
+//
+// Each component is a top-level named export (not a property on a namespace
+// object) so React Server Components create a client reference for it. That lets
+// consumers render these directly in a Server Component tree without adding their
+// own `'use client'` boundary — the `'use client'` directive lives on each leaf
+// file, and re-exporting forwards those references unchanged. A namespace object
+// (`UserProfile.Account`) would expose only the object as a client reference and
+// break property access across the RSC boundary.
+//
+// The export roster is maintained once, in the per-directory `index` files under
+// `../composed`; this module just re-exports it.
+export * from '../composed';
diff --git a/packages/ui/tsdown.config.mts b/packages/ui/tsdown.config.mts
index fcf7e5e089e..f5b8a74e396 100644
--- a/packages/ui/tsdown.config.mts
+++ b/packages/ui/tsdown.config.mts
@@ -41,6 +41,7 @@ export default defineConfig(({ watch }) => {
'./src/internal/index.ts',
'./src/themes/index.ts',
'./src/themes/experimental.ts',
+ './src/experimental/index.ts',
],
outDir: './dist',
unbundle: true,