Skip to content
5 changes: 3 additions & 2 deletions integration/templates/express-vite/src/client/main.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
13 changes: 11 additions & 2 deletions packages/astro/src/internal/create-clerk-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,24 @@ async function getClerkJsEntryChunk<TUi extends Ui = Ui>(options?: AstroClerkCre
await loadClerkJsScript(options);
}

/**
* 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' && 'version' in ui;
}

/**
* Gets the ClerkUI constructor, either from options or by loading the script.
* Returns early if window.__internal_ClerkUiCtor already exists.
*/
async function getClerkUiEntryChunk<TUi extends Ui = Ui>(
options?: AstroClerkCreateInstanceParams<TUi>,
): Promise<ClerkUiConstructor> {
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);
Expand Down
6 changes: 6 additions & 0 deletions packages/astro/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ type AstroClerkIntegrationParams<TUi extends Ui = Ui> = Without<
* The URL that `@clerk/ui` should be hot-loaded from.
*/
clerkUiUrl?: string;
/**
* The UI module configuration. Accepts either:
* - A version object for hot loading with version pinning
* - The ClerkUI class constructor for direct module usage
*/
ui?: TUi;
};

type AstroClerkCreateInstanceParams<TUi extends Ui = Ui> = AstroClerkIntegrationParams<TUi> & {
Expand Down
12 changes: 7 additions & 5 deletions packages/chrome-extension/src/react/ClerkProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
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 type { UiModule } from '@clerk/ui/internal';
import { ClerkUI } from '@clerk/ui';
import type { Appearance } from '@clerk/ui/internal';
import React from 'react';

import { createClerkClient } from '../internal/clerk';
import type { StorageCache } from '../internal/utils/storage';

type ChromeExtensionClerkProviderProps<TUi extends Ui = Ui> = ClerkReactProviderProps<TUi> & {
// Chrome extension always bundles @clerk/ui, so we use the specific UiModule type
type ChromeExtensionClerkProviderProps = Omit<ClerkReactProviderProps<UiModule<Appearance>>, 'ui'> & {
/**
* @experimental
* @description Enables the listener to sync host cookies on changes.
Expand All @@ -18,7 +20,7 @@ type ChromeExtensionClerkProviderProps<TUi extends Ui = Ui> = ClerkReactProvider
syncHost?: string;
};

export function ClerkProvider<TUi extends Ui = Ui>(props: ChromeExtensionClerkProviderProps<TUi>): JSX.Element | null {
export function ClerkProvider(props: ChromeExtensionClerkProviderProps): JSX.Element | null {
const { children, storageCache, syncHost, __experimental_syncHostListener, ...rest } = props;
const { publishableKey = '' } = props;

Expand All @@ -36,7 +38,7 @@ export function ClerkProvider<TUi extends Ui = Ui>(props: ChromeExtensionClerkPr
<ClerkReactProvider
{...rest}
Clerk={clerkInstance}
clerkUiCtor={ClerkUi}
ui={ClerkUI}
standardBrowser={!syncHost}
>
{children}
Expand Down
20 changes: 16 additions & 4 deletions packages/react/src/isomorphicClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,15 +508,27 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
return global.Clerk;
}

/**
* 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' && 'version' in ui;
}

private async getClerkUiEntryChunk(): Promise<ClerkUiConstructor> {
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 = typeof this.options.ui === 'object' ? 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,
Expand Down
19 changes: 16 additions & 3 deletions packages/react/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,22 @@ export type ClerkProviderProps<TUi extends Ui = Ui> = Omit<IsomorphicClerkOption
*/
appearance?: ExtractAppearanceType<TUi, Appearance>;
/**
* 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:
* - A version object (e.g., `ui` export from `@clerk/ui`) for hot loading with version pinning
* - The ClerkUI class constructor (e.g., `ClerkUI` export 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.
*
* @example
* // Hot loading with version pinning
* import { ui } from '@clerk/ui';
* <ClerkProvider ui={ui} />
*
* @example
* // Direct module usage (bundled with your app)
* import { ClerkUI } from '@clerk/ui';
* <ClerkProvider ui={ClerkUI} />
*/
ui?: TUi;
};
Expand Down
23 changes: 18 additions & 5 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClerkUiConstructor>;
/**
Expand Down Expand Up @@ -2370,11 +2373,21 @@ export type IsomorphicClerkOptions = Without<ClerkOptions, 'isSatellite'> & {
*/
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:
* - A version object `{ version: string; url?: string }` for hot loading with version pinning
* - The ClerkUI class constructor for direct module usage (bypasses hot loading)
*
* @example
* // Hot loading with version pinning
* import { ui } from '@clerk/ui';
* <ClerkProvider ui={ui} />
*
* @example
* // Direct module usage (bundled with your app)
* import { ClerkUI } from '@clerk/ui';
* <ClerkProvider ui={ClerkUI} />
*/
ui?: { version: string; url?: string };
ui?: { version: string; url?: string } | ClerkUiConstructor;
} & MultiDomainAndOrProxy;

export interface LoadedClerk extends Clerk {
Expand Down
14 changes: 12 additions & 2 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import type { Ui } from './internal';
import type { UiModule, UiVersion } from './internal';
import type { Appearance } from './internal/appearance';

import { ClerkUi as ClerkUiClass } from './ClerkUi';

declare const PACKAGE_VERSION: string;

/**
* Default ui object for Clerk UI components
* Tagged with the internal Appearance type for type-safe appearance prop inference
* Used for version pinning with hot loading
*/
export const ui = {
version: PACKAGE_VERSION,
} as Ui<Appearance>;
} as UiVersion<Appearance>;

/**
* ClerkUI class constructor for direct module usage
* Use this when you want to bundle @clerk/ui directly instead of hot loading
* Tagged with the internal Appearance type for type-safe appearance prop inference
*/
export const ClerkUI = ClerkUiClass as unknown as UiModule<Appearance>;
38 changes: 35 additions & 3 deletions packages/ui/src/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ declare const Tags: unique symbol;
type Tagged<BaseType, Tag extends PropertyKey> = BaseType & { [Tags]: { [K in Tag]: void } };

/**
* Ui type that carries appearance type information via phantom property
* UiVersion type that carries appearance type information via phantom property
* Tagged to ensure only official ui objects from @clerk/ui can be used
* Used for version pinning with hot loading
*/
export type Ui<A = any> = Tagged<
export type UiVersion<A = any> = Tagged<
{
version: string;
url?: string;
Expand All @@ -35,6 +36,37 @@ export type Ui<A = any> = Tagged<
'ClerkUi'
>;

/**
* UiModule type represents the ClerkUi class constructor
* Used when bundling @clerk/ui directly instead of hot loading
* Tagged to ensure only official ClerkUi class from @clerk/ui can be used
*/
export type UiModule<A = any> = Tagged<
{
/**
* The version string of the UI module
*/
version: string;
/**
* Constructor signature - must be callable with new
*/
new (...args: any[]): any;
/**
* Phantom property for type-level appearance inference
* This property never exists at runtime
*/
__appearanceType?: A;
},
'ClerkUiModule'
>;

/**
* Ui type that accepts either:
* - UiVersion: version pinning object for hot loading
* - UiModule: ClerkUi class constructor for direct module usage
*/
export type Ui<A = any> = UiVersion<A> | UiModule<A>;

export type {
AlphaColorScale,
Appearance,
Expand Down Expand Up @@ -94,4 +126,4 @@ export type {
export const localUiForTesting = {
version: PACKAGE_VERSION,
url: 'http://localhost:4011/npm/ui.browser.js',
} as Ui<Appearance & { newprop?: string }>;
} as UiVersion<Appearance & { newprop?: string }>;
14 changes: 12 additions & 2 deletions packages/vue/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,24 @@ export const clerkPlugin: Plugin<[PluginOptions]> = {
sdkMetadata: pluginOptions.sdkMetadata || SDK_METADATA,
} as LoadClerkJsScriptOptions;

/**
* 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' && '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 clerkUiCtorPromise = isUiConstructor(pluginOptions.ui)
? Promise.resolve(pluginOptions.ui)
: (async () => {
await loadClerkUiScript(options);
if (!window.__internal_ClerkUiCtor) {
Expand Down
Loading