diff --git a/.changeset/fiery-peas-see.md b/.changeset/fiery-peas-see.md
new file mode 100644
index 00000000000..cf286537142
--- /dev/null
+++ b/.changeset/fiery-peas-see.md
@@ -0,0 +1,25 @@
+---
+'@clerk/astro': minor
+'@clerk/chrome-extension': minor
+'@clerk/nextjs': minor
+'@clerk/react': minor
+'@clerk/shared': minor
+'@clerk/ui': minor
+'@clerk/vue': minor
+---
+
+Unify UI module configuration into a single `ui` prop on `ClerkProvider`. The prop accepts either:
+- The `version` export from `@clerk/ui` for hot loading with version pinning
+- The `ClerkUI` class constructor from `@clerk/ui` for direct module usage (bundled with your app)
+
+```tsx
+// Hot loading with version pinning
+import { version } from '@clerk/ui';
+
+
+// Direct module usage (bundled with your app)
+import { ClerkUI } from '@clerk/ui';
+
+```
+
+Only legitimate exports from `@clerk/ui` are accepted. The exports are branded with a symbol that is validated at runtime.
diff --git a/integration/templates/express-vite/src/client/main.ts b/integration/templates/express-vite/src/client/main.ts
index bf19f46d7b7..3ab7bb3f96e 100644
--- a/integration/templates/express-vite/src/client/main.ts
+++ b/integration/templates/express-vite/src/client/main.ts
@@ -1,13 +1,14 @@
import { Clerk } from '@clerk/clerk-js';
-import { ClerkUi } from '@clerk/ui/entry';
+import { ClerkUI } from '@clerk/ui';
const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
document.addEventListener('DOMContentLoaded', async function () {
const clerk = new Clerk(publishableKey);
+ // Using clerkUiCtor internally to pass the constructor to clerk.load()
await clerk.load({
- clerkUiCtor: ClerkUi,
+ clerkUiCtor: ClerkUI as any,
});
if (clerk.isSignedIn) {
diff --git a/packages/astro/src/internal/create-clerk-instance.ts b/packages/astro/src/internal/create-clerk-instance.ts
index e1cbd520144..f07c2f498bf 100644
--- a/packages/astro/src/internal/create-clerk-instance.ts
+++ b/packages/astro/src/internal/create-clerk-instance.ts
@@ -108,6 +108,37 @@ async function getClerkJsEntryChunk(options?: AstroClerkCre
await loadClerkJsScript(options);
}
+/**
+ * The well-known symbol used to identify legitimate @clerk/ui exports.
+ * Uses Symbol.for() to ensure the same symbol is used across module boundaries.
+ */
+const UI_BRAND_SYMBOL = Symbol.for('clerk:ui');
+
+/**
+ * Checks if the provided ui option is a legitimate @clerk/ui export
+ */
+function isLegitimateUiExport(ui: unknown): boolean {
+ if (!ui || (typeof ui !== 'object' && typeof ui !== 'function')) {
+ return false;
+ }
+ return (ui as any).__brand === UI_BRAND_SYMBOL;
+}
+
+/**
+ * Checks if the provided ui option is a ClerkUi constructor (class)
+ * rather than a version pinning object
+ */
+function isUiConstructor(ui: unknown): ui is ClerkUiConstructor {
+ return typeof ui === 'function' && isLegitimateUiExport(ui);
+}
+
+/**
+ * Checks if the provided ui option is a version object for hot loading
+ */
+function isUiVersion(ui: unknown): ui is { version: string; url?: string } {
+ return typeof ui === 'object' && ui !== null && isLegitimateUiExport(ui) && 'version' in ui;
+}
+
/**
* Gets the ClerkUI constructor, either from options or by loading the script.
* Returns early if window.__internal_ClerkUiCtor already exists.
@@ -115,11 +146,24 @@ async function getClerkJsEntryChunk(options?: AstroClerkCre
async function getClerkUiEntryChunk(
options?: AstroClerkCreateInstanceParams,
): Promise {
- if (options?.clerkUiCtor) {
- return options.clerkUiCtor;
+ // If ui is a constructor (ClerkUI class), use it directly
+ if (isUiConstructor(options?.ui)) {
+ return options.ui;
}
- await loadClerkUiScript(options);
+ // Get version info if it's a legitimate version object
+ const uiVersion = isUiVersion(options?.ui) ? options.ui : undefined;
+
+ await loadClerkUiScript(
+ options
+ ? {
+ ...options,
+ clerkUiVersion: uiVersion?.version,
+ // Only override clerkUiUrl if uiVersion provides a url, otherwise keep existing
+ clerkUiUrl: uiVersion?.url || options.clerkUiUrl,
+ }
+ : undefined,
+ );
if (!window.__internal_ClerkUiCtor) {
throw new Error('Failed to download latest Clerk UI. Contact support@clerk.com.');
diff --git a/packages/astro/src/types.ts b/packages/astro/src/types.ts
index 7f0613e5968..d4012666add 100644
--- a/packages/astro/src/types.ts
+++ b/packages/astro/src/types.ts
@@ -36,6 +36,14 @@ type AstroClerkIntegrationParams = Without<
* The URL that `@clerk/ui` should be hot-loaded from.
*/
clerkUiUrl?: string;
+ /**
+ * The UI module configuration. Accepts either:
+ * - The `version` export from `@clerk/ui` for hot loading with version pinning
+ * - The `ClerkUI` class constructor from `@clerk/ui` for direct module usage
+ *
+ * Note: Only legitimate exports from `@clerk/ui` are accepted (validated via symbol).
+ */
+ ui?: TUi;
};
type AstroClerkCreateInstanceParams = AstroClerkIntegrationParams & {
diff --git a/packages/chrome-extension/src/react/ClerkProvider.tsx b/packages/chrome-extension/src/react/ClerkProvider.tsx
index 45237484496..8c676092448 100644
--- a/packages/chrome-extension/src/react/ClerkProvider.tsx
+++ b/packages/chrome-extension/src/react/ClerkProvider.tsx
@@ -1,14 +1,15 @@
import type { Clerk } from '@clerk/clerk-js/no-rhc';
import type { ClerkProviderProps as ClerkReactProviderProps } from '@clerk/react';
import { ClerkProvider as ClerkReactProvider } from '@clerk/react';
-import type { Ui } from '@clerk/react/internal';
-import { ClerkUi } from '@clerk/ui/entry';
+import { ClerkUI } from '@clerk/ui';
+import type { Appearance, UiModule } from '@clerk/ui/internal';
import React from 'react';
import { createClerkClient } from '../internal/clerk';
import type { StorageCache } from '../internal/utils/storage';
-type ChromeExtensionClerkProviderProps = ClerkReactProviderProps & {
+// Chrome extension always bundles @clerk/ui, so we use the specific UiModule type
+type ChromeExtensionClerkProviderProps = Omit>, 'ui'> & {
/**
* @experimental
* @description Enables the listener to sync host cookies on changes.
@@ -18,7 +19,7 @@ type ChromeExtensionClerkProviderProps = ClerkReactProvider
syncHost?: string;
};
-export function ClerkProvider(props: ChromeExtensionClerkProviderProps): JSX.Element | null {
+export function ClerkProvider(props: ChromeExtensionClerkProviderProps): JSX.Element | null {
const { children, storageCache, syncHost, __experimental_syncHostListener, ...rest } = props;
const { publishableKey = '' } = props;
@@ -36,7 +37,7 @@ export function ClerkProvider(props: ChromeExtensionClerkPr
{children}
diff --git a/packages/nextjs/src/utils/clerk-script.tsx b/packages/nextjs/src/utils/clerk-script.tsx
index aceb76c4d9d..d6c3da2d4dd 100644
--- a/packages/nextjs/src/utils/clerk-script.tsx
+++ b/packages/nextjs/src/utils/clerk-script.tsx
@@ -50,6 +50,9 @@ export function ClerkScripts({ router }: { router: ClerkScriptProps['router'] })
return null;
}
+ // Only version objects have url property, constructors don't
+ const uiVersionUrl = ui && typeof ui === 'object' && 'url' in ui ? ui.url : undefined;
+
const opts = {
publishableKey,
clerkJSUrl,
@@ -59,7 +62,7 @@ export function ClerkScripts({ router }: { router: ClerkScriptProps['router'] })
domain,
proxyUrl,
clerkUiVersion: ui?.version,
- clerkUiUrl: ui?.url || clerkUiUrl,
+ clerkUiUrl: uiVersionUrl || clerkUiUrl,
};
return (
diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts
index b39bf352b8c..2acc37a7d24 100644
--- a/packages/react/src/isomorphicClerk.ts
+++ b/packages/react/src/isomorphicClerk.ts
@@ -508,15 +508,50 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
return global.Clerk;
}
+ /**
+ * The well-known symbol used to identify legitimate @clerk/ui exports.
+ * Uses Symbol.for() to ensure the same symbol is used across module boundaries.
+ */
+ private static readonly UI_BRAND_SYMBOL = Symbol.for('clerk:ui');
+
+ /**
+ * Checks if the provided ui option is a legitimate @clerk/ui export
+ */
+ private isLegitimateUiExport(ui: unknown): boolean {
+ if (!ui || (typeof ui !== 'object' && typeof ui !== 'function')) {
+ return false;
+ }
+ return (ui as any).__brand === IsomorphicClerk.UI_BRAND_SYMBOL;
+ }
+
+ /**
+ * Checks if the provided ui option is a ClerkUi constructor (class)
+ * rather than a version pinning object
+ */
+ private isUiConstructor(ui: unknown): ui is ClerkUiConstructor {
+ return typeof ui === 'function' && this.isLegitimateUiExport(ui);
+ }
+
+ /**
+ * Checks if the provided ui option is a version object for hot loading
+ */
+ private isUiVersion(ui: unknown): ui is { version: string; url?: string } {
+ return typeof ui === 'object' && ui !== null && this.isLegitimateUiExport(ui) && 'version' in ui;
+ }
+
private async getClerkUiEntryChunk(): Promise {
- if (this.options.clerkUiCtor) {
- return this.options.clerkUiCtor;
+ // If ui is a constructor (ClerkUI class), use it directly
+ if (this.isUiConstructor(this.options.ui)) {
+ return this.options.ui;
}
+ // Otherwise, hot load the UI script based on version/url
+ const uiVersion = this.isUiVersion(this.options.ui) ? this.options.ui : undefined;
+
await loadClerkUiScript({
...this.options,
- clerkUiVersion: this.options.ui?.version,
- clerkUiUrl: this.options.ui?.url || this.options.clerkUiUrl,
+ clerkUiVersion: uiVersion?.version,
+ clerkUiUrl: uiVersion?.url || this.options.clerkUiUrl,
publishableKey: this.#publishableKey,
proxyUrl: this.proxyUrl,
domain: this.domain,
diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts
index c769d58afac..0612a16ec44 100644
--- a/packages/react/src/types.ts
+++ b/packages/react/src/types.ts
@@ -53,9 +53,25 @@ export type ClerkProviderProps = Omit;
/**
- * Optional object to pin the UI version your app will be using. Useful when you've extensively customize the look and feel of the
- * components using the appearance prop.
- * Note: When `ui` is used, appearance is automatically typed based on the specific UI version.
+ * Optional prop to configure the UI module. Accepts either:
+ * - The `version` export from `@clerk/ui` for hot loading with version pinning
+ * - The `ClerkUI` class constructor from `@clerk/ui` for direct module usage
+ *
+ * When using version pinning, the UI is hot-loaded from the CDN.
+ * When using the ClerkUI constructor directly, the UI is bundled with your app.
+ *
+ * Note: Only legitimate exports from `@clerk/ui` are accepted. Arbitrary objects
+ * or strings will be ignored.
+ *
+ * @example
+ * // Hot loading with version pinning
+ * import { version } from '@clerk/ui';
+ *
+ *
+ * @example
+ * // Direct module usage (bundled with your app)
+ * import { ClerkUI } from '@clerk/ui';
+ *
*/
ui?: TUi;
};
diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts
index 33b15d3bfe8..1444928bfc3 100644
--- a/packages/shared/src/types/clerk.ts
+++ b/packages/shared/src/types/clerk.ts
@@ -1083,7 +1083,10 @@ export type ClerkOptions = ClerkOptionsNavigation &
AfterMultiSessionSingleSignOutUrl &
ClerkUnsafeOptions & {
/**
- * Clerk UI entrypoint.
+ * @internal
+ * Clerk UI constructor. Used internally to pass the resolved UI constructor to clerk.load().
+ * For public usage, prefer the `ui` prop on ClerkProvider which accepts both version objects
+ * and the ClerkUI constructor.
*/
clerkUiCtor?: ClerkUiConstructor | Promise;
/**
@@ -2370,11 +2373,24 @@ export type IsomorphicClerkOptions = Without & {
*/
nonce?: string;
/**
- * @internal
- * This is a structural-only type for the `ui` object that can be passed
- * to Clerk.load() and ClerkProvider
+ * The UI module configuration. Accepts either:
+ * - The `version` export from `@clerk/ui` for hot loading with version pinning
+ * - The `ClerkUI` class constructor from `@clerk/ui` for direct module usage
+ *
+ * Note: Only legitimate exports from `@clerk/ui` are accepted (validated via symbol).
+ * Arbitrary objects or strings will be ignored.
+ *
+ * @example
+ * // Hot loading with version pinning
+ * import { version } from '@clerk/ui';
+ *
+ *
+ * @example
+ * // Direct module usage (bundled with your app)
+ * import { ClerkUI } from '@clerk/ui';
+ *
*/
- ui?: { version: string; url?: string };
+ ui?: { __brand: symbol; version: string; url?: string } | ClerkUiConstructor;
} & MultiDomainAndOrProxy;
export interface LoadedClerk extends Clerk {
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index cc3aa52b41b..b63b17e384a 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -1,12 +1,37 @@
-import type { Ui } from './internal';
+import { ClerkUi as ClerkUiClass } from './ClerkUi';
+import type { UiModule, UiVersion } from './internal';
import type { Appearance } from './internal/appearance';
declare const PACKAGE_VERSION: string;
/**
- * Default ui object for Clerk UI components
- * Tagged with the internal Appearance type for type-safe appearance prop inference
+ * Symbol used to identify legitimate @clerk/ui exports at runtime.
+ * This prevents arbitrary objects from being passed to the ui prop.
+ * @internal
*/
-export const ui = {
+export const UI_BRAND_SYMBOL = Symbol.for('clerk:ui');
+
+/**
+ * Version object for Clerk UI components.
+ * Use this for version pinning with hot loading from CDN.
+ *
+ * @example
+ * import { version } from '@clerk/ui';
+ *
+ */
+export const version = {
+ __brand: UI_BRAND_SYMBOL,
version: PACKAGE_VERSION,
-} as Ui;
+} as UiVersion;
+
+/**
+ * ClerkUI class constructor for direct module usage.
+ * Use this when you want to bundle @clerk/ui directly instead of hot loading.
+ *
+ * @example
+ * import { ClerkUI } from '@clerk/ui';
+ *
+ */
+// Add the brand symbol to the class
+(ClerkUiClass as any).__brand = UI_BRAND_SYMBOL;
+export const ClerkUI = ClerkUiClass as unknown as UiModule;
diff --git a/packages/ui/src/internal/index.ts b/packages/ui/src/internal/index.ts
index 2a9e39b207e..da39c2679f5 100644
--- a/packages/ui/src/internal/index.ts
+++ b/packages/ui/src/internal/index.ts
@@ -6,34 +6,77 @@ export type { WithInternalRouting } from './routing';
/**
* Extracts the appearance type from a Ui object. We got 3 cases:
* - If the Ui type has __appearanceType with a specific type, extract it
- * - If __appearanceType is 'any', fallback to base Appearance type
+ * - If __appearanceType is 'any' or 'unknown', fallback to base Appearance type
* - Otherwise, fallback to the base Appearance type
*/
export type ExtractAppearanceType = T extends { __appearanceType?: infer A }
- ? 0 extends 1 & A // Check if A is 'any' (this trick works because 1 & any = any, and 0 extends any)
+ ? unknown extends A // If A is 'any' or 'unknown', fallback to Default
? Default
: A
: Default;
-declare const Tags: unique symbol;
-type Tagged = BaseType & { [Tags]: { [K in Tag]: void } };
+/**
+ * The well-known symbol key used to identify legitimate @clerk/ui exports.
+ * Uses Symbol.for() to ensure the same symbol is used across module boundaries.
+ * @internal
+ */
+export const UI_BRAND_SYMBOL_KEY = 'clerk:ui';
+
+/**
+ * UiVersion type that carries appearance type information via phantom property
+ * Used for version pinning with hot loading
+ *
+ * @typeParam A - The appearance type for styling customization
+ */
+export type UiVersion = {
+ /**
+ * Brand symbol to identify legitimate @clerk/ui exports at runtime
+ */
+ __brand: symbol;
+ version: string;
+ url?: string;
+ /**
+ * Phantom property for type-level appearance inference
+ * This property never exists at runtime
+ */
+ __appearanceType?: A;
+};
+
+/**
+ * UiModule type represents the ClerkUi class constructor
+ * Used when bundling @clerk/ui directly instead of hot loading
+ *
+ * @typeParam A - The appearance type for styling customization
+ * @typeParam I - The instance type returned by the constructor (defaults to unknown for external consumers)
+ */
+export type UiModule = {
+ /**
+ * Brand symbol to identify legitimate @clerk/ui exports at runtime
+ */
+ __brand: symbol;
+ /**
+ * The version string of the UI module
+ */
+ version: string;
+ /**
+ * Constructor signature - must be callable with new
+ */
+ new (...args: unknown[]): I;
+ /**
+ * Phantom property for type-level appearance inference
+ * This property never exists at runtime
+ */
+ __appearanceType?: A;
+};
/**
- * Ui type that carries appearance type information via phantom property
- * Tagged to ensure only official ui objects from @clerk/ui can be used
+ * Ui type that accepts either:
+ * - UiVersion: version pinning object for hot loading
+ * - UiModule: ClerkUi class constructor for direct module usage
+ *
+ * @typeParam A - The appearance type for styling customization
*/
-export type Ui = Tagged<
- {
- version: string;
- url?: string;
- /**
- * Phantom property for type-level appearance inference
- * This property never exists at runtime
- */
- __appearanceType?: A;
- },
- 'ClerkUi'
->;
+export type Ui = UiVersion | UiModule;
export type {
AlphaColorScale,
@@ -92,6 +135,7 @@ export type {
* Do not use
*/
export const localUiForTesting = {
+ __brand: Symbol.for(UI_BRAND_SYMBOL_KEY),
version: PACKAGE_VERSION,
url: 'http://localhost:4011/npm/ui.browser.js',
-} as Ui;
+} as UiVersion;
diff --git a/packages/vue/src/plugin.ts b/packages/vue/src/plugin.ts
index 91a89ffe91d..2e7e8f71300 100644
--- a/packages/vue/src/plugin.ts
+++ b/packages/vue/src/plugin.ts
@@ -72,16 +72,55 @@ export const clerkPlugin: Plugin<[PluginOptions]> = {
sdkMetadata: pluginOptions.sdkMetadata || SDK_METADATA,
} as LoadClerkJsScriptOptions;
+ /**
+ * The well-known symbol used to identify legitimate @clerk/ui exports.
+ * Uses Symbol.for() to ensure the same symbol is used across module boundaries.
+ */
+ const UI_BRAND_SYMBOL = Symbol.for('clerk:ui');
+
+ /**
+ * Checks if the provided ui option is a legitimate @clerk/ui export
+ */
+ const isLegitimateUiExport = (ui: unknown): boolean => {
+ if (!ui || (typeof ui !== 'object' && typeof ui !== 'function')) {
+ return false;
+ }
+ return (ui as any).__brand === UI_BRAND_SYMBOL;
+ };
+
+ /**
+ * Checks if the provided ui option is a ClerkUi constructor (class)
+ * rather than a version pinning object
+ */
+ const isUiConstructor = (ui: unknown): ui is ClerkUiConstructor => {
+ return typeof ui === 'function' && isLegitimateUiExport(ui);
+ };
+
+ /**
+ * Checks if the provided ui option is a version object for hot loading
+ */
+ const isUiVersion = (ui: unknown): ui is { version: string; url?: string } => {
+ return typeof ui === 'object' && ui !== null && isLegitimateUiExport(ui) && 'version' in ui;
+ };
+
// We need this check for SSR apps like Nuxt as it will try to run this code on the server
// and loadClerkJsScript contains browser-specific code
if (inBrowser()) {
void (async () => {
try {
const clerkPromise = loadClerkJsScript(options);
- const clerkUiCtorPromise = pluginOptions.clerkUiCtor
- ? Promise.resolve(pluginOptions.clerkUiCtor)
+ // If ui is a constructor (ClerkUI class), use it directly
+ // Otherwise, hot load the UI script
+ const uiVersion = isUiVersion(pluginOptions.ui) ? pluginOptions.ui : undefined;
+ const clerkUiCtorPromise = isUiConstructor(pluginOptions.ui)
+ ? Promise.resolve(pluginOptions.ui)
: (async () => {
- await loadClerkUiScript(options);
+ await loadClerkUiScript({
+ ...options,
+ clerkUiVersion: uiVersion?.version,
+ // Only override clerkUiUrl if uiVersion provides a url, otherwise keep existing
+ clerkUiUrl: uiVersion?.url || pluginOptions.clerkUiUrl,
+ });
if (!window.__internal_ClerkUiCtor) {
throw new Error('Failed to download latest Clerk UI. Contact support@clerk.com.');
}