diff --git a/.changeset/shiny-owls-dance.md b/.changeset/shiny-owls-dance.md
new file mode 100644
index 00000000000..62aaa93a156
--- /dev/null
+++ b/.changeset/shiny-owls-dance.md
@@ -0,0 +1,19 @@
+---
+'@clerk/ui': minor
+'@clerk/react': minor
+'@clerk/vue': minor
+'@clerk/astro': minor
+'@clerk/chrome-extension': minor
+'@clerk/shared': minor
+---
+
+Add `ui` prop to ClerkProvider for passing `@clerk/ui`
+
+Usage:
+```tsx
+import { ui } from '@clerk/ui';
+
+
+ ...
+
+```
diff --git a/integration/templates/custom-flows-react-vite/src/main.tsx b/integration/templates/custom-flows-react-vite/src/main.tsx
index 08bb652e4c2..225a3ddd20f 100644
--- a/integration/templates/custom-flows-react-vite/src/main.tsx
+++ b/integration/templates/custom-flows-react-vite/src/main.tsx
@@ -15,7 +15,7 @@ createRoot(document.getElementById('root')!).render(
router.push(to)}
routerReplace={to => router.replace(to)}
clerkJSUrl={process.env.EXPO_PUBLIC_CLERK_JS_URL}
- clerkUiUrl={process.env.EXPO_PUBLIC_CLERK_UI_URL}
+ clerkUIUrl={process.env.EXPO_PUBLIC_CLERK_UI_URL}
appearance={{
options: {
showOptionalFields: true,
diff --git a/integration/templates/express-vite/src/client/main.ts b/integration/templates/express-vite/src/client/main.ts
index bf19f46d7b7..92df0f15660 100644
--- a/integration/templates/express-vite/src/client/main.ts
+++ b/integration/templates/express-vite/src/client/main.ts
@@ -1,14 +1,12 @@
import { Clerk } from '@clerk/clerk-js';
-import { ClerkUi } from '@clerk/ui/entry';
+import { ui } from '@clerk/ui';
const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
document.addEventListener('DOMContentLoaded', async function () {
const clerk = new Clerk(publishableKey);
- await clerk.load({
- clerkUiCtor: ClerkUi,
- });
+ await clerk.load({ ui });
if (clerk.isSignedIn) {
document.getElementById('app')!.innerHTML = `
diff --git a/integration/templates/react-cra/src/index.tsx b/integration/templates/react-cra/src/index.tsx
index 65271a25b7e..81039d51a20 100644
--- a/integration/templates/react-cra/src/index.tsx
+++ b/integration/templates/react-cra/src/index.tsx
@@ -10,7 +10,7 @@ root.render(
{
return (
navigate(to)}
routerReplace={(to: string) => navigate(to, { replace: true })}
appearance={{
diff --git a/integration/templates/tanstack-react-start/src/routes/__root.tsx b/integration/templates/tanstack-react-start/src/routes/__root.tsx
index 4dd7cf9d763..b9adc012c75 100644
--- a/integration/templates/tanstack-react-start/src/routes/__root.tsx
+++ b/integration/templates/tanstack-react-start/src/routes/__root.tsx
@@ -29,7 +29,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
()
// These are not provided when the "bundled" integration is used
const clerkJSUrl = (params as any)?.clerkJSUrl as string | undefined;
- const clerkUiUrl = (params as any)?.clerkUiUrl as string | undefined;
+ const clerkUIUrl = (params as any)?.clerkUIUrl as string | undefined;
const clerkJSVariant = (params as any)?.clerkJSVariant as string | undefined;
const clerkJSVersion = (params as any)?.clerkJSVersion as string | undefined;
@@ -61,7 +61,7 @@ function createIntegration()
...buildEnvVarFromOption(proxyUrl, 'PUBLIC_CLERK_PROXY_URL'),
...buildEnvVarFromOption(domain, 'PUBLIC_CLERK_DOMAIN'),
...buildEnvVarFromOption(clerkJSUrl, 'PUBLIC_CLERK_JS_URL'),
- ...buildEnvVarFromOption(clerkUiUrl, 'PUBLIC_CLERK_UI_URL'),
+ ...buildEnvVarFromOption(clerkUIUrl, 'PUBLIC_CLERK_UI_URL'),
...buildEnvVarFromOption(clerkJSVariant, 'PUBLIC_CLERK_JS_VARIANT'),
...buildEnvVarFromOption(clerkJSVersion, 'PUBLIC_CLERK_JS_VERSION'),
},
diff --git a/packages/astro/src/internal/__tests__/create-clerk-instance.test.ts b/packages/astro/src/internal/__tests__/create-clerk-instance.test.ts
new file mode 100644
index 00000000000..46dc1479c80
--- /dev/null
+++ b/packages/astro/src/internal/__tests__/create-clerk-instance.test.ts
@@ -0,0 +1,127 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mockLoadClerkUiScript = vi.fn();
+const mockLoadClerkJsScript = vi.fn();
+
+vi.mock('@clerk/shared/loadClerkJsScript', () => ({
+ loadClerkJsScript: (...args: unknown[]) => mockLoadClerkJsScript(...args),
+ loadClerkUiScript: (...args: unknown[]) => mockLoadClerkUiScript(...args),
+ setClerkJsLoadingErrorPackageName: vi.fn(),
+}));
+
+// Mock nanostores
+vi.mock('../../stores/external', () => ({
+ $clerkStore: { notify: vi.fn() },
+}));
+
+vi.mock('../../stores/internal', () => ({
+ $clerk: { get: vi.fn(), set: vi.fn() },
+ $csrState: { setKey: vi.fn() },
+}));
+
+vi.mock('../invoke-clerk-astro-js-functions', () => ({
+ invokeClerkAstroJSFunctions: vi.fn(),
+}));
+
+vi.mock('../mount-clerk-astro-js-components', () => ({
+ mountAllClerkAstroJSComponents: vi.fn(),
+}));
+
+const mockClerkUICtor = vi.fn();
+
+describe('getClerkUiEntryChunk', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.resetModules();
+ (window as any).__internal_ClerkUICtor = undefined;
+ (window as any).Clerk = undefined;
+ });
+
+ afterEach(() => {
+ (window as any).__internal_ClerkUICtor = undefined;
+ (window as any).Clerk = undefined;
+ });
+
+ it('preserves clerkUIUrl from options when options.ui.url is not provided', async () => {
+ mockLoadClerkUiScript.mockImplementation(async () => {
+ (window as any).__internal_ClerkUICtor = mockClerkUICtor;
+ return null;
+ });
+
+ mockLoadClerkJsScript.mockImplementation(async () => {
+ (window as any).Clerk = {
+ load: vi.fn().mockResolvedValue(undefined),
+ addListener: vi.fn(),
+ };
+ return null;
+ });
+
+ // Dynamically import to get fresh module with mocks
+ const { createClerkInstance } = await import('../create-clerk-instance');
+
+ // Call createClerkInstance with clerkUIUrl but without ui.url
+ await createClerkInstance({
+ publishableKey: 'pk_test_xxx',
+ clerkUIUrl: 'https://custom.selfhosted.example.com/ui.js',
+ });
+
+ expect(mockLoadClerkUiScript).toHaveBeenCalled();
+ const loadClerkUiScriptCall = mockLoadClerkUiScript.mock.calls[0]?.[0] as Record;
+ expect(loadClerkUiScriptCall?.clerkUIUrl).toBe('https://custom.selfhosted.example.com/ui.js');
+ });
+
+ it('prefers options.ui.url over options.clerkUIUrl when both are provided', async () => {
+ mockLoadClerkUiScript.mockImplementation(async () => {
+ (window as any).__internal_ClerkUICtor = mockClerkUICtor;
+ return null;
+ });
+
+ mockLoadClerkJsScript.mockImplementation(async () => {
+ (window as any).Clerk = {
+ load: vi.fn().mockResolvedValue(undefined),
+ addListener: vi.fn(),
+ };
+ return null;
+ });
+
+ const { createClerkInstance } = await import('../create-clerk-instance');
+
+ await createClerkInstance({
+ publishableKey: 'pk_test_xxx',
+ clerkUIUrl: 'https://fallback.example.com/ui.js',
+ ui: {
+ version: '1.0.0',
+ url: 'https://preferred.example.com/ui.js',
+ } as any,
+ });
+
+ expect(mockLoadClerkUiScript).toHaveBeenCalled();
+ const loadClerkUiScriptCall = mockLoadClerkUiScript.mock.calls[0]?.[0] as Record;
+ expect(loadClerkUiScriptCall?.clerkUIUrl).toBe('https://preferred.example.com/ui.js');
+ });
+
+ it('does not set clerkUIUrl when neither options.ui.url nor options.clerkUIUrl is provided', async () => {
+ mockLoadClerkUiScript.mockImplementation(async () => {
+ (window as any).__internal_ClerkUICtor = mockClerkUICtor;
+ return null;
+ });
+
+ mockLoadClerkJsScript.mockImplementation(async () => {
+ (window as any).Clerk = {
+ load: vi.fn().mockResolvedValue(undefined),
+ addListener: vi.fn(),
+ };
+ return null;
+ });
+
+ const { createClerkInstance } = await import('../create-clerk-instance');
+
+ await createClerkInstance({
+ publishableKey: 'pk_test_xxx',
+ });
+
+ expect(mockLoadClerkUiScript).toHaveBeenCalled();
+ const loadClerkUiScriptCall = mockLoadClerkUiScript.mock.calls[0]?.[0] as Record;
+ expect(loadClerkUiScriptCall?.clerkUIUrl).toBeUndefined();
+ });
+});
diff --git a/packages/astro/src/internal/create-clerk-instance.ts b/packages/astro/src/internal/create-clerk-instance.ts
index e1cbd520144..2b7de8618d1 100644
--- a/packages/astro/src/internal/create-clerk-instance.ts
+++ b/packages/astro/src/internal/create-clerk-instance.ts
@@ -4,7 +4,7 @@ import {
setClerkJsLoadingErrorPackageName,
} from '@clerk/shared/loadClerkJsScript';
import type { ClerkOptions } from '@clerk/shared/types';
-import type { ClerkUiConstructor } from '@clerk/shared/ui';
+import type { ClerkUIConstructor } from '@clerk/shared/ui';
import type { Ui } from '@clerk/ui/internal';
import { $clerkStore } from '../stores/external';
@@ -40,7 +40,7 @@ async function createClerkInstanceInternal(options?: AstroC
// Both functions return early if the scripts are already loaded
// (e.g., via middleware-injected script tags in the HTML head).
const clerkJsChunk = getClerkJsEntryChunk(options);
- const clerkUiCtor = getClerkUiEntryChunk(options);
+ const ClerkUI = getClerkUiEntryChunk(options);
await clerkJsChunk;
@@ -58,8 +58,12 @@ async function createClerkInstanceInternal(options?: AstroC
routerPush: createNavigationHandler(window.history.pushState.bind(window.history)),
routerReplace: createNavigationHandler(window.history.replaceState.bind(window.history)),
...options,
- // Pass the clerk-ui constructor promise to clerk.load()
- clerkUiCtor,
+ // Pass the clerk-ui constructor promise inside ui object
+ ui: {
+ version: options?.ui?.version,
+ url: options?.ui?.url,
+ ClerkUI,
+ },
} as unknown as ClerkOptions;
initOptions = clerkOptions;
@@ -109,23 +113,33 @@ async function getClerkJsEntryChunk(options?: AstroClerkCre
}
/**
- * Gets the ClerkUI constructor, either from options or by loading the script.
- * Returns early if window.__internal_ClerkUiCtor already exists.
+ * Gets the ClerkUI constructor, either from bundled UI or by loading from CDN.
*/
async function getClerkUiEntryChunk(
options?: AstroClerkCreateInstanceParams,
-): Promise {
- if (options?.clerkUiCtor) {
- return options.clerkUiCtor;
+): Promise {
+ // Use bundled UI constructor if provided via options.ui.ClerkUI
+ const bundledClerkUI = (options?.ui as { ClerkUI?: ClerkUIConstructor })?.ClerkUI;
+ if (bundledClerkUI) {
+ return bundledClerkUI;
}
- await loadClerkUiScript(options);
-
- if (!window.__internal_ClerkUiCtor) {
+ // Fall back to loading UI from CDN with version pinning from ui.version
+ await loadClerkUiScript(
+ options
+ ? {
+ ...options,
+ clerkUIVersion: options.ui?.version,
+ clerkUIUrl: options.ui?.url ?? options.clerkUIUrl,
+ }
+ : undefined,
+ );
+
+ if (!window.__internal_ClerkUICtor) {
throw new Error('Failed to download latest Clerk UI. Contact support@clerk.com.');
}
- return window.__internal_ClerkUiCtor;
+ return window.__internal_ClerkUICtor;
}
export { createClerkInstance, updateClerkOptions };
diff --git a/packages/astro/src/internal/create-injection-script-runner.ts b/packages/astro/src/internal/create-injection-script-runner.ts
index e07b298edc0..3a6b035ed01 100644
--- a/packages/astro/src/internal/create-injection-script-runner.ts
+++ b/packages/astro/src/internal/create-injection-script-runner.ts
@@ -22,7 +22,12 @@ function createInjectionScriptRunner(creator: CreateClerkInstanceInternalFn) {
clientSafeVars = JSON.parse(clientSafeVarsContainer.textContent || '{}');
}
- await creator(mergeEnvVarsWithParams({ ...astroClerkOptions, ...clientSafeVars }));
+ // Pass `ui` separately to avoid TypeScript declaration file issues with the
+ // branded Ui type that contains an unexported Tags symbol
+ await creator({
+ ...mergeEnvVarsWithParams({ ...astroClerkOptions, ...clientSafeVars }),
+ ui: astroClerkOptions?.ui,
+ });
}
return runner;
diff --git a/packages/astro/src/internal/merge-env-vars-with-params.ts b/packages/astro/src/internal/merge-env-vars-with-params.ts
index dea24762b16..04740cbe66a 100644
--- a/packages/astro/src/internal/merge-env-vars-with-params.ts
+++ b/packages/astro/src/internal/merge-env-vars-with-params.ts
@@ -15,9 +15,12 @@ const mergeEnvVarsWithParams = (params?: AstroClerkIntegrationParams & { publish
publishableKey: paramPublishableKey,
telemetry: paramTelemetry,
clerkJSUrl: paramClerkJSUrl,
- clerkUiUrl: paramClerkUiUrl,
+ clerkUIUrl: paramClerkUIUrl,
clerkJSVariant: paramClerkJSVariant,
clerkJSVersion: paramClerkJSVersion,
+ // Extract `ui` separately to avoid spreading the branded Ui type which contains
+ // an unexported Tags symbol that breaks TypeScript declaration file generation.
+ ui: _paramUi,
...rest
} = params || {};
@@ -28,7 +31,7 @@ const mergeEnvVarsWithParams = (params?: AstroClerkIntegrationParams & { publish
proxyUrl: paramProxy || import.meta.env.PUBLIC_CLERK_PROXY_URL,
domain: paramDomain || import.meta.env.PUBLIC_CLERK_DOMAIN,
publishableKey: paramPublishableKey || import.meta.env.PUBLIC_CLERK_PUBLISHABLE_KEY || '',
- clerkUiUrl: paramClerkUiUrl || import.meta.env.PUBLIC_CLERK_UI_URL,
+ clerkUIUrl: paramClerkUIUrl || import.meta.env.PUBLIC_CLERK_UI_URL,
clerkJSUrl: paramClerkJSUrl || import.meta.env.PUBLIC_CLERK_JS_URL,
clerkJSVariant: paramClerkJSVariant || import.meta.env.PUBLIC_CLERK_JS_VARIANT,
clerkJSVersion: paramClerkJSVersion || import.meta.env.PUBLIC_CLERK_JS_VERSION,
diff --git a/packages/astro/src/server/build-clerk-hotload-script.ts b/packages/astro/src/server/build-clerk-hotload-script.ts
index b3cf7d37089..c60ca7028a4 100644
--- a/packages/astro/src/server/build-clerk-hotload-script.ts
+++ b/packages/astro/src/server/build-clerk-hotload-script.ts
@@ -19,7 +19,7 @@ function buildClerkHotloadScript(locals: APIContext['locals']) {
publishableKey,
});
const clerkUiScriptSrc = clerkUiScriptUrl({
- clerkUiUrl: getSafeEnv(locals).clerkUiUrl,
+ clerkUIUrl: getSafeEnv(locals).clerkUIUrl,
domain,
proxyUrl,
publishableKey,
diff --git a/packages/astro/src/server/get-safe-env.ts b/packages/astro/src/server/get-safe-env.ts
index 7ec1824029b..5caf01d5779 100644
--- a/packages/astro/src/server/get-safe-env.ts
+++ b/packages/astro/src/server/get-safe-env.ts
@@ -31,7 +31,7 @@ function getSafeEnv(context: ContextOrLocals) {
signInUrl: getContextEnvVar('PUBLIC_CLERK_SIGN_IN_URL', context),
signUpUrl: getContextEnvVar('PUBLIC_CLERK_SIGN_UP_URL', context),
clerkJsUrl: getContextEnvVar('PUBLIC_CLERK_JS_URL', context),
- clerkUiUrl: getContextEnvVar('PUBLIC_CLERK_UI_URL', context),
+ clerkUIUrl: getContextEnvVar('PUBLIC_CLERK_UI_URL', context),
clerkJsVariant: getContextEnvVar('PUBLIC_CLERK_JS_VARIANT', context) as 'headless' | '' | undefined,
clerkJsVersion: getContextEnvVar('PUBLIC_CLERK_JS_VERSION', context),
apiVersion: getContextEnvVar('CLERK_API_VERSION', context),
diff --git a/packages/astro/src/types.ts b/packages/astro/src/types.ts
index 7f0613e5968..219d57fe510 100644
--- a/packages/astro/src/types.ts
+++ b/packages/astro/src/types.ts
@@ -7,7 +7,7 @@ import type {
ShowProps,
Without,
} from '@clerk/shared/types';
-import type { ClerkUiConstructor } from '@clerk/shared/ui';
+import type { ClerkUIConstructor } from '@clerk/shared/ui';
import type { Appearance, Ui } from '@clerk/ui/internal';
type AstroClerkUpdateOptions = Pick & {
@@ -35,7 +35,12 @@ type AstroClerkIntegrationParams = Without<
/**
* The URL that `@clerk/ui` should be hot-loaded from.
*/
- clerkUiUrl?: string;
+ clerkUIUrl?: string;
+ /**
+ * The Clerk UI bundle to use. When provided with a bundled UI via
+ * `ui.ClerkUI`, it will be used instead of loading from CDN.
+ */
+ ui?: TUi;
};
type AstroClerkCreateInstanceParams = AstroClerkIntegrationParams & {
@@ -59,7 +64,7 @@ declare global {
__astro_clerk_component_props: Map>>;
__astro_clerk_function_props: Map>>;
Clerk: BrowserClerk;
- __internal_ClerkUiCtor?: ClerkUiConstructor;
+ __internal_ClerkUICtor?: ClerkUIConstructor;
}
}
diff --git a/packages/astro/vitest.config.ts b/packages/astro/vitest.config.ts
new file mode 100644
index 00000000000..9dbc1341d39
--- /dev/null
+++ b/packages/astro/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: ['./vitest.setup.ts'],
+ },
+});
diff --git a/packages/astro/vitest.setup.ts b/packages/astro/vitest.setup.ts
new file mode 100644
index 00000000000..f1792b77288
--- /dev/null
+++ b/packages/astro/vitest.setup.ts
@@ -0,0 +1,6 @@
+import { vi } from 'vitest';
+
+import packageJson from './package.json';
+
+vi.stubGlobal('PACKAGE_NAME', packageJson.name);
+vi.stubGlobal('PACKAGE_VERSION', packageJson.version);
diff --git a/packages/chrome-extension/src/react/ClerkProvider.tsx b/packages/chrome-extension/src/react/ClerkProvider.tsx
index 45237484496..6b013a2e0d0 100644
--- a/packages/chrome-extension/src/react/ClerkProvider.tsx
+++ b/packages/chrome-extension/src/react/ClerkProvider.tsx
@@ -2,7 +2,7 @@ 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 { ui } from '@clerk/ui';
import React from 'react';
import { createClerkClient } from '../internal/clerk';
@@ -32,11 +32,14 @@ export function ClerkProvider(props: ChromeExtensionClerkPr
return null;
}
+ // Chrome extension must use bundled UI (no CDN access in extensions)
+ const bundledUi = { ...ui, __internal_forceBundledUI: true } as typeof ui;
+
return (
{children}
diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts
index 315e0822f31..baf707bfcfe 100644
--- a/packages/clerk-js/sandbox/app.ts
+++ b/packages/clerk-js/sandbox/app.ts
@@ -446,7 +446,9 @@ void (async () => {
...(componentControls.clerk.getProps() ?? {}),
signInUrl: '/sign-in',
signUpUrl: '/sign-up',
- clerkUiCtor: window.__internal_ClerkUiCtor,
+ ui: {
+ ClerkUI: window.__internal_ClerkUICtor,
+ },
});
renderCurrentRoute();
updateVariables();
diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html
index 924e159fcd2..4983019bdf9 100644
--- a/packages/clerk-js/sandbox/template.html
+++ b/packages/clerk-js/sandbox/template.html
@@ -355,7 +355,7 @@
-
+