diff --git a/.changeset/shiny-owls-dance.md b/.changeset/shiny-owls-dance.md
new file mode 100644
index 00000000000..fc4839c0112
--- /dev/null
+++ b/.changeset/shiny-owls-dance.md
@@ -0,0 +1,11 @@
+---
+'@clerk/ui': minor
+'@clerk/react': minor
+'@clerk/nextjs': minor
+'@clerk/vue': minor
+'@clerk/astro': minor
+'@clerk/chrome-extension': minor
+'@clerk/shared': minor
+---
+
+Add `ui` prop to `ClerkProvider` for passing `@clerk/ui`
diff --git a/integration/templates/express-vite/src/client/main.ts b/integration/templates/express-vite/src/client/main.ts
index 7c46c42a815..7dcc4eb0a36 100644
--- a/integration/templates/express-vite/src/client/main.ts
+++ b/integration/templates/express-vite/src/client/main.ts
@@ -1,5 +1,5 @@
import { Clerk } from '@clerk/clerk-js';
-import { ClerkUi } from '@clerk/ui/entry';
+import { ClerkUI } from '@clerk/ui/entry';
const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
@@ -7,7 +7,7 @@ document.addEventListener('DOMContentLoaded', async function () {
const clerk = new Clerk(publishableKey);
await clerk.load({
- clerkUICtor: ClerkUi,
+ ui: { ClerkUI },
});
if (clerk.isSignedIn) {
diff --git a/integration/tests/next-build.test.ts b/integration/tests/next-build.test.ts
index 055c1d631e3..ffdba908205 100644
--- a/integration/tests/next-build.test.ts
+++ b/integration/tests/next-build.test.ts
@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test';
import type { Application } from '../models/application';
import { appConfigs } from '../presets';
+import { linkPackage } from '../presets/utils';
type RenderingModeTestCase = {
name: string;
@@ -23,6 +24,69 @@ function getIndicator(buildOutput: string, type: 'Static' | 'Dynamic') {
.split(' ')[0];
}
+test.describe('next build - bundled UI with react-server condition @nextjs', () => {
+ test.describe.configure({ mode: 'parallel' });
+ let app: Application;
+
+ test.beforeAll(async () => {
+ test.setTimeout(90_000); // Wait for app to be ready
+ app = await appConfigs.next.appRouter
+ .clone()
+ .addDependency('@clerk/ui', linkPackage('ui'))
+ .addFile(
+ 'src/app/layout.tsx',
+ () => `import './globals.css';
+import { Inter } from 'next/font/google';
+import { ClerkProvider } from '@clerk/nextjs';
+import { ui } from '@clerk/ui';
+
+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}
+
+
+ );
+}
+ `,
+ )
+ .commit();
+ await app.setup();
+ await app.withEnv(appConfigs.envs.withEmailCodes);
+ await app.build();
+ });
+
+ test.afterAll(async () => {
+ await app.teardown();
+ });
+
+ test('When ui prop is used in server component layout, builds successfully', () => {
+ // The layout.tsx imports { ui } from "@clerk/ui" and passes ui={ui} to ClerkProvider
+ // This tests the react-server conditional export which provides a server-safe marker
+ // The build should succeed without errors about client-only modules in server components
+ expect(app.buildOutput).not.toMatch(/error/i);
+ expect(app.buildOutput).toContain('Generating static pages');
+ });
+
+ test('Static pages remain static with bundled UI', () => {
+ // Get the static indicator from the build output
+ const staticIndicator = getIndicator(app.buildOutput, 'Static');
+
+ // /_not-found should still be static even with bundled UI
+ const notFoundPageLine = app.buildOutput.split('\n').find(msg => msg.includes('/_not-found'));
+
+ expect(notFoundPageLine).toContain(staticIndicator);
+ });
+});
+
test.describe('next build - provider as client component @nextjs', () => {
test.describe.configure({ mode: 'parallel' });
let app: Application;
diff --git a/packages/astro/package.json b/packages/astro/package.json
index 24a5fd9641b..86a88fd1341 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -86,7 +86,8 @@
"lint": "eslint src env.d.ts",
"lint:attw": "attw --pack . --profile esm-only --ignore-rules internal-resolution-error",
"lint:publint": "pnpm copy:components && publint",
- "publish:local": "pnpm yalc push --replace --sig"
+ "publish:local": "pnpm yalc push --replace --sig",
+ "test": "vitest run"
},
"dependencies": {
"@clerk/backend": "workspace:^",
diff --git a/packages/astro/src/env.d.ts b/packages/astro/src/env.d.ts
index 1240941e1e2..933fc2aea3f 100644
--- a/packages/astro/src/env.d.ts
+++ b/packages/astro/src/env.d.ts
@@ -6,6 +6,7 @@ interface InternalEnv {
readonly PUBLIC_CLERK_JS_URL?: string;
readonly PUBLIC_CLERK_JS_VERSION?: string;
readonly PUBLIC_CLERK_UI_URL?: string;
+ readonly PUBLIC_CLERK_UI_VERSION?: string;
readonly PUBLIC_CLERK_PREFETCH_UI?: string;
readonly CLERK_API_KEY?: string;
readonly CLERK_API_URL?: string;
diff --git a/packages/astro/src/integration/create-integration.ts b/packages/astro/src/integration/create-integration.ts
index fc19a8371c3..753ecd2b325 100644
--- a/packages/astro/src/integration/create-integration.ts
+++ b/packages/astro/src/integration/create-integration.ts
@@ -21,6 +21,7 @@ function createIntegration()
// These are not provided when the "bundled" integration is used
const clerkJSUrl = (params as any)?.clerkJSUrl as string | undefined;
const clerkJSVersion = (params as any)?.clerkJSVersion as string | undefined;
+ const clerkUIVersion = (params as any)?.clerkUIVersion as string | undefined;
const prefetchUI = (params as any)?.prefetchUI as boolean | undefined;
return {
@@ -57,6 +58,7 @@ function createIntegration()
...buildEnvVarFromOption(domain, 'PUBLIC_CLERK_DOMAIN'),
...buildEnvVarFromOption(clerkJSUrl, 'PUBLIC_CLERK_JS_URL'),
...buildEnvVarFromOption(clerkJSVersion, 'PUBLIC_CLERK_JS_VERSION'),
+ ...buildEnvVarFromOption(clerkUIVersion, 'PUBLIC_CLERK_UI_VERSION'),
...buildEnvVarFromOption(prefetchUI === false ? 'false' : undefined, 'PUBLIC_CLERK_PREFETCH_UI'),
},
@@ -165,6 +167,7 @@ function createClerkEnvSchema() {
PUBLIC_CLERK_DOMAIN: envField.string({ context: 'client', access: 'public', optional: true, url: true }),
PUBLIC_CLERK_JS_URL: envField.string({ context: 'client', access: 'public', optional: true, url: true }),
PUBLIC_CLERK_JS_VERSION: envField.string({ context: 'client', access: 'public', optional: true }),
+ PUBLIC_CLERK_UI_VERSION: envField.string({ context: 'client', access: 'public', optional: true }),
PUBLIC_CLERK_PREFETCH_UI: envField.string({ context: 'client', access: 'public', optional: true }),
PUBLIC_CLERK_UI_URL: envField.string({ context: 'client', access: 'public', optional: true, url: true }),
PUBLIC_CLERK_TELEMETRY_DISABLED: envField.boolean({ context: 'client', access: 'public', optional: true }),
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..e9f62f67a99
--- /dev/null
+++ b/packages/astro/src/internal/__tests__/create-clerk-instance.test.ts
@@ -0,0 +1,97 @@
+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', 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
+ 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('does not set clerkUIUrl when 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;
+ });
+
+ 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 675e405382c..8365746503a 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;
@@ -59,7 +59,7 @@ async function createClerkInstanceInternal(options?: AstroC
routerReplace: createNavigationHandler(window.history.replaceState.bind(window.history)),
...options,
// Pass the clerk-ui constructor promise to clerk.load()
- clerkUICtor,
+ ui: { ...options?.ui, ClerkUI },
} as unknown as ClerkOptions;
initOptions = clerkOptions;
@@ -115,10 +115,10 @@ async function getClerkJsEntryChunk(options?: AstroClerkCre
*/
async function getClerkUIEntryChunk(
options?: AstroClerkCreateInstanceParams,
-): Promise {
- // Honor explicit clerkUICtor even when prefetchUI=false
- if (options?.clerkUICtor) {
- return options.clerkUICtor;
+): Promise {
+ // Support bundled UI via ui.ClerkUI prop
+ if (options?.ui?.ClerkUI) {
+ return options.ui.ClerkUI;
}
if (options?.prefetchUI === false) {
diff --git a/packages/astro/src/internal/create-injection-script-runner.ts b/packages/astro/src/internal/create-injection-script-runner.ts
index e07b298edc0..422fdca3c98 100644
--- a/packages/astro/src/internal/create-injection-script-runner.ts
+++ b/packages/astro/src/internal/create-injection-script-runner.ts
@@ -22,7 +22,9 @@ function createInjectionScriptRunner(creator: CreateClerkInstanceInternalFn) {
clientSafeVars = JSON.parse(clientSafeVarsContainer.textContent || '{}');
}
- await creator(mergeEnvVarsWithParams({ ...astroClerkOptions, ...clientSafeVars }));
+ await creator({
+ ...mergeEnvVarsWithParams({ ...astroClerkOptions, ...clientSafeVars }),
+ });
}
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 1cac532cc6f..bcecc86d790 100644
--- a/packages/astro/src/internal/merge-env-vars-with-params.ts
+++ b/packages/astro/src/internal/merge-env-vars-with-params.ts
@@ -36,7 +36,8 @@ const mergeEnvVarsWithParams = (params?: AstroClerkIntegrationParams & { publish
telemetry: paramTelemetry,
clerkJSUrl: paramClerkJSUrl,
clerkJSVersion: paramClerkJSVersion,
- clerkUIUrl: paramClerkUiUrl,
+ clerkUIUrl: paramClerkUIUrl,
+ clerkUIVersion: paramClerkUIVersion,
prefetchUI: paramPrefetchUI,
...rest
} = params || {};
@@ -50,7 +51,8 @@ const mergeEnvVarsWithParams = (params?: AstroClerkIntegrationParams & { publish
publishableKey: paramPublishableKey || import.meta.env.PUBLIC_CLERK_PUBLISHABLE_KEY || '',
clerkJSUrl: paramClerkJSUrl || import.meta.env.PUBLIC_CLERK_JS_URL,
clerkJSVersion: paramClerkJSVersion || import.meta.env.PUBLIC_CLERK_JS_VERSION,
- clerkUIUrl: paramClerkUiUrl || import.meta.env.PUBLIC_CLERK_UI_URL,
+ clerkUIUrl: paramClerkUIUrl || import.meta.env.PUBLIC_CLERK_UI_URL,
+ clerkUIVersion: paramClerkUIVersion || import.meta.env.PUBLIC_CLERK_UI_VERSION,
prefetchUI: mergePrefetchUIConfig(paramPrefetchUI),
telemetry: paramTelemetry || {
disabled: isTruthy(import.meta.env.PUBLIC_CLERK_TELEMETRY_DISABLED),
diff --git a/packages/astro/src/server/build-clerk-hotload-script.ts b/packages/astro/src/server/build-clerk-hotload-script.ts
index 2ab079b908f..5510759b387 100644
--- a/packages/astro/src/server/build-clerk-hotload-script.ts
+++ b/packages/astro/src/server/build-clerk-hotload-script.ts
@@ -34,8 +34,9 @@ function buildClerkHotloadScript(locals: APIContext['locals']) {
return clerkJsScript + '\n';
}
- const clerkUiScriptSrc = clerkUIScriptUrl({
+ const clerkUIScriptSrc = clerkUIScriptUrl({
clerkUIUrl: env.clerkUIUrl,
+ clerkUIVersion: env.clerkUIVersion,
domain,
proxyUrl,
publishableKey,
@@ -47,14 +48,14 @@ function buildClerkHotloadScript(locals: APIContext['locals']) {
// registration (which happens when React code runs @clerk/ui/register).
// When loadClerkUIScript() later adds a