diff --git a/.changeset/profile-composed-shell.md b/.changeset/profile-composed-shell.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/profile-composed-shell.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/ui/src/composed/APIKeysSection.tsx b/packages/ui/src/composed/APIKeysSection.tsx
new file mode 100644
index 00000000000..3e1369dcef8
--- /dev/null
+++ b/packages/ui/src/composed/APIKeysSection.tsx
@@ -0,0 +1,15 @@
+'use client';
+
+import { Suspense, type ComponentType, type ReactNode } from 'react';
+
+import { CardStateProvider } from '../elements/contexts';
+
+export function APIKeysSection({ page: Page }: { page: ComponentType }): ReactNode {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/composed/BillingSection.tsx b/packages/ui/src/composed/BillingSection.tsx
new file mode 100644
index 00000000000..f6c30a1139e
--- /dev/null
+++ b/packages/ui/src/composed/BillingSection.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+import { Suspense, type ComponentType, type ReactNode } from 'react';
+
+import { RouteContext } from '../router/RouteContext';
+import { useBillingRouter } from './useBillingRouter';
+
+type BillingSectionProps = {
+ billing: ComponentType;
+ plans: ComponentType;
+ statement: ComponentType;
+ paymentAttempt: ComponentType;
+};
+
+export function BillingSection({
+ billing: Billing,
+ plans: Plans,
+ statement: Statement,
+ paymentAttempt: PaymentAttempt,
+}: BillingSectionProps): ReactNode {
+ const { router, route } = useBillingRouter();
+
+ let content: ReactNode;
+ switch (route.page) {
+ case 'plans':
+ content = ;
+ break;
+ case 'statement':
+ content = ;
+ break;
+ case 'payment-attempt':
+ content = ;
+ break;
+ default:
+ content = ;
+ }
+
+ return (
+
+ {content}
+
+ );
+}
diff --git a/packages/ui/src/composed/PageContext.tsx b/packages/ui/src/composed/PageContext.tsx
new file mode 100644
index 00000000000..b16f319d803
--- /dev/null
+++ b/packages/ui/src/composed/PageContext.tsx
@@ -0,0 +1,5 @@
+import { createContext } from 'react';
+
+type PageId = 'account' | 'security' | 'general';
+
+export const PageContext = createContext(null);
diff --git a/packages/ui/src/composed/ProfileProviderShell.tsx b/packages/ui/src/composed/ProfileProviderShell.tsx
new file mode 100644
index 00000000000..d1abf54b29c
--- /dev/null
+++ b/packages/ui/src/composed/ProfileProviderShell.tsx
@@ -0,0 +1,150 @@
+'use client';
+
+// Composed UserProfile / OrganizationProfile mount outside the clerk-js portal
+// tree, so this shell rebuilds the providers normally split between
+// `LazyProviders` and `LazyComponentRenderer` / `LazyModalRenderer` in
+// `packages/ui/src/lazyModules/providers.tsx`. `ClerkContextProvider` is
+// intentionally omitted — the consumer's `` supplies `clerk` via
+// `useClerk()`. The emotion cache is keyed per clerk instance in
+// `styleCacheStore` so sibling composed roots don't duplicate style insertions.
+
+import { ClerkRuntimeError } from '@clerk/shared/error';
+import type { ModuleManager } from '@clerk/shared/moduleManager';
+import type { EnvironmentResource, LoadedClerk } from '@clerk/shared/types';
+// eslint-disable-next-line no-restricted-imports
+import { CacheProvider } from '@emotion/react';
+import type { PropsWithChildren, ReactNode } from 'react';
+import { useMemo } from 'react';
+
+import { AppearanceProvider } from '@/ui/customizables/AppearanceContext';
+import { FlowMetadataProvider } from '@/ui/elements/contexts';
+import type { Appearance, Elements } from '@/ui/internal/appearance';
+import { getStyleCache, setStyleCache } from '@/ui/internal/styleCacheStore';
+import { RouteContext } from '@/ui/router/RouteContext';
+import { InternalThemeProvider } from '@/ui/styledSystem';
+import { createEmotionCache } from '@/ui/styledSystem/createEmotionCache';
+import { extractCssLayerNameFromAppearance } from '@/ui/utils/extractCssLayerNameFromAppearance';
+
+import { EnvironmentProvider } from '../contexts/EnvironmentContext';
+import { ModuleManagerProvider } from '../contexts/ModuleManagerContext';
+import { OptionsProvider } from '../contexts/OptionsContext';
+import { AppearanceOverrides } from '../elements/AppearanceOverrides';
+import { createComposedRouter } from './stubRouter';
+
+// Used when `clerk.__internal_moduleManager` is `undefined`. In a correctly wired app clerk-js
+// exposes its ModuleManager through that getter, so reaching this means the loaded clerk-js is too
+// old to expose it (an older clerk-js also predates composed profiles entirely). Fail loudly on the
+// first dynamic import (Web3, billing, password strength) instead of silently resolving `undefined`
+// and surfacing later as an opaque access on the missing module.
+export const fallbackModuleManager: ModuleManager = {
+ import: () =>
+ Promise.reject(
+ new ClerkRuntimeError(
+ 'Composed profile components could not resolve a Clerk module manager: this Clerk instance does not expose one. This usually means the loaded @clerk/clerk-js is too old to support composed profiles.',
+ { code: 'composed_module_manager_unavailable' },
+ ),
+ ),
+};
+
+const composedOverrides: Elements = {
+ profilePageContent: { padding: 0 },
+};
+
+type ProfileProviderShellProps = PropsWithChildren<{
+ clerk: LoadedClerk;
+ environment: EnvironmentResource;
+ moduleManager: ModuleManager;
+ appearanceKey: 'userProfile' | 'organizationProfile';
+ flow: 'userProfile' | 'organizationProfile';
+ globalAppearance: Appearance | undefined;
+ appearance?: Appearance;
+}>;
+
+type SharedStyleCacheProviderProps = PropsWithChildren<{
+ clerk: LoadedClerk;
+ nonce?: string;
+ cssLayerName?: string;
+}>;
+
+// One emotion cache per clerk instance, so sibling composed roots share inserts.
+function SharedStyleCacheProvider({ clerk, nonce, cssLayerName, children }: SharedStyleCacheProviderProps): ReactNode {
+ const cache = useMemo(() => {
+ const existing = getStyleCache(clerk);
+ if (existing) {
+ return existing;
+ }
+ const next = createEmotionCache({ nonce, cssLayerName });
+ setStyleCache(clerk, next);
+ return next;
+ }, [clerk, nonce, cssLayerName]);
+
+ return {children};
+}
+
+export function ProfileProviderShell({
+ children,
+ clerk,
+ environment,
+ moduleManager,
+ appearanceKey,
+ flow,
+ globalAppearance,
+ appearance,
+}: ProfileProviderShellProps): ReactNode {
+ // currentPath is left empty: composed has no Clerk-internal navigation. Each
+ // section owns its own CardStateProvider, so errors clear on section unmount
+ // (single-section mounting). Side-by-side sections keep independent error
+ // state — a consumer URL change wouldn't be a meaningful signal to clear
+ // either, so observing it would only cause spurious clears.
+ const router = useMemo(() => createComposedRouter(clerk.navigate), [clerk]);
+ // Match the portal path's normalization (Components.tsx:209) so a cssLayerName
+ // nested inside appearance.theme gets hoisted to top-level for @layer wrapping.
+ const normalizedGlobalAppearance = useMemo(
+ () => extractCssLayerNameFromAppearance(globalAppearance),
+ [globalAppearance],
+ );
+ const options = useMemo(
+ () => ({
+ localization: clerk.__internal_getOption('localization'),
+ supportEmail: clerk.__internal_getOption('supportEmail'),
+ }),
+ [clerk],
+ );
+
+ return (
+
+ {/* parsed appearance for cl-* styled components */}
+
+ {/* flow= for Flow.Root/Part data-clerk-* selectors */}
+
+ {/* Emotion ThemeProvider over parsed theme */}
+
+ {/* dynamic-import bridge (Web3) */}
+
+ {/* threads localization + supportEmail from the consumer's */}
+
+ {/* read by useEnvironment() across MFA/account sections */}
+
+ {/* router stub: navigate→clerk.navigate, matches/refresh no-op */}
+
+ {/* zero out profilePageContent padding when embedded */}
+ {children}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/composed/__tests__/stub-limitations.test.ts b/packages/ui/src/composed/__tests__/stub-limitations.test.ts
new file mode 100644
index 00000000000..fffd9d61236
--- /dev/null
+++ b/packages/ui/src/composed/__tests__/stub-limitations.test.ts
@@ -0,0 +1,131 @@
+import { renderHook, act } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { createComposedRouter, stubRouter } from '../stubRouter';
+import { useBillingRouter } from '../useBillingRouter';
+
+describe('createComposedRouter', () => {
+ it('navigate delegates to clerkNavigate for same-origin paths', async () => {
+ const clerkNavigate = vi.fn().mockResolvedValue(undefined);
+ const router = createComposedRouter(clerkNavigate);
+
+ await router.navigate('/dashboard');
+
+ expect(clerkNavigate).toHaveBeenCalledWith('/dashboard');
+ });
+
+ it('navigate delegates to clerkNavigate for relative paths', async () => {
+ const clerkNavigate = vi.fn().mockResolvedValue(undefined);
+ const router = createComposedRouter(clerkNavigate);
+
+ await router.navigate('../');
+
+ expect(clerkNavigate).toHaveBeenCalledWith('../');
+ });
+
+ it('navigate delegates to clerkNavigate for external URLs', async () => {
+ const clerkNavigate = vi.fn().mockResolvedValue(undefined);
+ const router = createComposedRouter(clerkNavigate);
+
+ await router.navigate('https://external.example.com/callback');
+
+ expect(clerkNavigate).toHaveBeenCalledWith('https://external.example.com/callback');
+ });
+
+ it('baseNavigate delegates to clerkNavigate with URL href', async () => {
+ const clerkNavigate = vi.fn().mockResolvedValue(undefined);
+ const router = createComposedRouter(clerkNavigate);
+
+ await router.baseNavigate(new URL('https://example.com/path'));
+
+ expect(clerkNavigate).toHaveBeenCalledWith('https://example.com/path');
+ });
+
+ it('resolve produces URLs relative to current location', () => {
+ const router = createComposedRouter(vi.fn());
+
+ const resolved = router.resolve('/some-path');
+ expect(resolved.pathname).toBe('/some-path');
+ });
+});
+
+describe('createComposedRouter — AIO-only APIs throw in dev', () => {
+ it('matches() throws', () => {
+ const router = createComposedRouter(vi.fn());
+ expect(() => router.matches('/foo')).toThrow(/not supported inside composed sections/);
+ });
+
+ it('refresh() throws', () => {
+ const router = createComposedRouter(vi.fn());
+ expect(() => router.refresh()).toThrow(/not supported inside composed sections/);
+ });
+
+ it('getMatchData() throws', () => {
+ const router = createComposedRouter(vi.fn());
+ expect(() => router.getMatchData('/foo')).toThrow(/not supported inside composed sections/);
+ });
+});
+
+describe('stubRouter fallback', () => {
+ it('is created with window.location.assign as navigator', () => {
+ // stubRouter is a pre-built instance that delegates to window.location.assign.
+ // We can't spy on window.location.assign in jsdom, but we verify it's a valid router.
+ expect(stubRouter.navigate).toBeDefined();
+ expect(stubRouter.baseNavigate).toBeDefined();
+ });
+});
+
+describe('useBillingRouter — in-memory by design', () => {
+ // Composed billing routing is purely React state — the consumer owns the
+ // page URL. Trade-off: back/forward, refresh, and deep-links do not preserve
+ // sub-route or tab state. These tests pin that decision.
+
+ let originalHash: string;
+
+ afterEach(() => {
+ window.location.hash = originalHash ?? '';
+ });
+
+ it('navigate() does not touch window.location.hash', async () => {
+ originalHash = window.location.hash;
+ const { result } = renderHook(() => useBillingRouter());
+
+ await act(async () => {
+ await result.current.router.navigate('plans');
+ });
+
+ expect(window.location.hash).toBe(originalHash);
+ expect(result.current.route.page).toBe('plans');
+ });
+
+ it('navigate() does not push a history entry', async () => {
+ const before = window.history.length;
+ const { result } = renderHook(() => useBillingRouter());
+
+ await act(async () => {
+ await result.current.router.navigate('statement/abc');
+ });
+
+ expect(window.history.length).toBe(before);
+ });
+});
+
+describe('createComposedRouter — SSR safety', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('resolve() does not throw when window is undefined', () => {
+ vi.stubGlobal('window', undefined);
+
+ const router = createComposedRouter(vi.fn());
+ expect(() => router.resolve('/some-path')).not.toThrow();
+ expect(router.resolve('/some-path').pathname).toBe('/some-path');
+ });
+
+ it('stubRouter.navigate is a no-op (does not throw) when window is undefined', async () => {
+ vi.stubGlobal('window', undefined);
+
+ await expect(stubRouter.navigate('/foo')).resolves.toBeUndefined();
+ });
+});
diff --git a/packages/ui/src/composed/createSection.tsx b/packages/ui/src/composed/createSection.tsx
new file mode 100644
index 00000000000..fb2e6b89042
--- /dev/null
+++ b/packages/ui/src/composed/createSection.tsx
@@ -0,0 +1,14 @@
+'use client';
+
+import type { ComponentType, ReactNode } from 'react';
+
+import { useRequirePage } from './useRequirePage';
+
+export function createSection(name: string, Component: ComponentType): () => ReactNode {
+ function Section(): ReactNode {
+ if (!useRequirePage(name)) return null;
+ return ;
+ }
+ Section.displayName = name;
+ return Section;
+}
diff --git a/packages/ui/src/composed/stubRouter.ts b/packages/ui/src/composed/stubRouter.ts
new file mode 100644
index 00000000000..0c3484aca52
--- /dev/null
+++ b/packages/ui/src/composed/stubRouter.ts
@@ -0,0 +1,57 @@
+import type { RouteContextValue } from '../router/RouteContext';
+
+const SSR_BASE = 'http://localhost/';
+
+// Composed sections render outside the AIO Route/Switch tree, so the stub
+// router has no path-matching state. Calls into these APIs almost always mean
+// someone added a , navigateToFlowStart(), or refresh() inside a
+// composed section — fail loudly in dev so that's caught at the test level
+// instead of silently rendering null.
+function unsupported(api: string): never {
+ throw new Error(
+ `[@clerk/ui composed] router.${api} is not supported inside composed sections. ` +
+ `Composed sections are leaf-only — , navigateToFlowStart(), router.refresh(), ` +
+ `getMatchData() and urlStateParam are AIO-only. Move routing to the consumer.`,
+ );
+}
+
+function maybeUnsupported(api: string, prodValue: T): T {
+ if (typeof __DEV__ === 'undefined' || __DEV__) {
+ unsupported(api);
+ }
+ return prodValue;
+}
+
+export function createComposedRouter(
+ clerkNavigate: (to: string) => Promise | void,
+ currentPath: string = '',
+): RouteContextValue {
+ return {
+ basePath: '',
+ startPath: '',
+ flowStartPath: '',
+ fullPath: '',
+ indexPath: '',
+ currentPath,
+ matches: () => maybeUnsupported('matches()', false),
+ navigate: async (to: string) => {
+ await clerkNavigate(to);
+ },
+ baseNavigate: async (toURL: URL) => {
+ await clerkNavigate(toURL.href);
+ },
+ resolve: (to: string) => new URL(to, typeof window !== 'undefined' ? window.location.href : SSR_BASE),
+ refresh: () => maybeUnsupported('refresh()', undefined),
+ params: {},
+ queryString: '',
+ queryParams: {},
+ getMatchData: () => maybeUnsupported('getMatchData()', false),
+ };
+}
+
+export const stubRouter: RouteContextValue = createComposedRouter(to => {
+ if (typeof window !== 'undefined') {
+ window.location.assign(to);
+ }
+ return Promise.resolve();
+});
diff --git a/packages/ui/src/composed/useBillingRouter.ts b/packages/ui/src/composed/useBillingRouter.ts
new file mode 100644
index 00000000000..bb9f03efb1b
--- /dev/null
+++ b/packages/ui/src/composed/useBillingRouter.ts
@@ -0,0 +1,98 @@
+// In-memory routing for composed billing — intentionally not URL-backed. The
+// consumer owns the page URL, so we don't touch hash/history. Trade-off:
+// back/forward, refresh, and deep-links don't preserve sub-route or tab state.
+import { useMemo, useState } from 'react';
+
+import type { RouteContextValue } from '../router/RouteContext';
+import { stubRouter } from './stubRouter';
+
+type BillingRoute =
+ | { page: 'billing' }
+ | { page: 'plans' }
+ | { page: 'statement'; statementId: string }
+ | { page: 'payment-attempt'; paymentAttemptId: string };
+
+function resolveNavigation(to: string): BillingRoute {
+ let path = to;
+ while (path.startsWith('../')) {
+ path = path.slice(3);
+ }
+
+ if (!path || path === '/') {
+ return { page: 'billing' };
+ }
+
+ if (path === 'plans') {
+ return { page: 'plans' };
+ }
+
+ const statementMatch = path.match(/^statement\/(.+)$/);
+ if (statementMatch) {
+ return { page: 'statement', statementId: statementMatch[1] };
+ }
+
+ const paymentMatch = path.match(/^payment-attempt\/(.+)$/);
+ if (paymentMatch) {
+ return { page: 'payment-attempt', paymentAttemptId: paymentMatch[1] };
+ }
+
+ return { page: 'billing' };
+}
+
+function pathFromRoute(route: BillingRoute): string {
+ switch (route.page) {
+ case 'plans':
+ return 'billing/plans';
+ case 'statement':
+ return `billing/statement/${route.statementId}`;
+ case 'payment-attempt':
+ return `billing/payment-attempt/${route.paymentAttemptId}`;
+ default:
+ return 'billing';
+ }
+}
+
+function paramsFromRoute(route: BillingRoute): Record {
+ switch (route.page) {
+ case 'statement':
+ return { statementId: route.statementId };
+ case 'payment-attempt':
+ return { paymentAttemptId: route.paymentAttemptId };
+ default:
+ return {};
+ }
+}
+
+export function useBillingRouter(): { router: RouteContextValue; route: BillingRoute } {
+ const [route, setRoute] = useState({ page: 'billing' });
+ const [queryParams, setQueryParams] = useState>({});
+
+ const router: RouteContextValue = useMemo(
+ () => ({
+ ...stubRouter,
+ currentPath: pathFromRoute(route),
+ params: paramsFromRoute(route),
+ queryParams,
+ queryString: new URLSearchParams(queryParams).toString(),
+ navigate: async (to: string, options?: { searchParams?: URLSearchParams }) => {
+ try {
+ const url = new URL(to);
+ if (url.origin !== window.location.origin) {
+ window.location.href = to;
+ return;
+ }
+ } catch {}
+ const newRoute = resolveNavigation(to);
+ setRoute(newRoute);
+ if (options?.searchParams) {
+ setQueryParams(Object.fromEntries(options.searchParams.entries()));
+ } else if (newRoute.page !== route.page) {
+ setQueryParams({});
+ }
+ },
+ }),
+ [route, queryParams],
+ );
+
+ return { router, route };
+}
diff --git a/packages/ui/src/composed/useRequirePage.ts b/packages/ui/src/composed/useRequirePage.ts
new file mode 100644
index 00000000000..e9f4c034f08
--- /dev/null
+++ b/packages/ui/src/composed/useRequirePage.ts
@@ -0,0 +1,16 @@
+import { useContext } from 'react';
+
+import { PageContext } from './PageContext';
+
+export function useRequirePage(componentName: string): boolean {
+ const page = useContext(PageContext);
+ if (!page) {
+ if (typeof __DEV__ === 'undefined' || __DEV__) {
+ throw new Error(
+ `<${componentName}> must be rendered inside a page component (e.g. , , ).`,
+ );
+ }
+ return false;
+ }
+ return true;
+}