From 2e52e40999623762f9f13fa8db386fcba43f5b02 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 9 Jan 2026 13:15:55 +0200 Subject: [PATCH 1/6] feat(nextjs): skip clerk-ui script injection for headless variant why: when using clerkJSVariant='headless', applications only need control components and don't require the full UI bundle. loading the unnecessary @clerk/ui script adds overhead without providing value. what changed: - clerk-script.tsx: conditionally render clerk-ui script tag only when clerkJSVariant !== 'headless' - integration template: read NEXT_PUBLIC_CLERK_JS_VARIANT env var and pass to ClerkProvider users can now set NEXT_PUBLIC_CLERK_JS_VARIANT='headless' to skip loading the ~100KB @clerk/ui bundle when using only control components. --- .../templates/next-app-router/src/app/layout.tsx | 1 + packages/nextjs/src/utils/clerk-script.tsx | 14 ++++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/integration/templates/next-app-router/src/app/layout.tsx b/integration/templates/next-app-router/src/app/layout.tsx index 42341d04adc..2db280398a6 100644 --- a/integration/templates/next-app-router/src/app/layout.tsx +++ b/integration/templates/next-app-router/src/app/layout.tsx @@ -12,6 +12,7 @@ export const metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {clerkJSVariant !== 'headless' && ( + + )} ); } From 6ab9de38cc49b4795108a714f39b6b84d43d674c Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 9 Jan 2026 13:16:07 +0200 Subject: [PATCH 2/6] feat(astro): skip clerk-ui script injection for headless variant why: when using clerkJSVariant='headless', applications only need control components and don't require the full UI bundle. loading the unnecessary @clerk/ui script adds overhead without providing value. what changed: - build-clerk-hotload-script: skip generating clerk-ui script tag when clerkJsVariant === 'headless' - create-clerk-instance: getClerkUiEntryChunk returns undefined for headless variant to skip client-side hot-loading users can now set clerkJSVariant='headless' to skip loading the ~100KB @clerk/ui bundle when using only control components. --- .../src/internal/create-clerk-instance.ts | 8 ++++- .../src/server/build-clerk-hotload-script.ts | 32 +++++++++++++------ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/astro/src/internal/create-clerk-instance.ts b/packages/astro/src/internal/create-clerk-instance.ts index e1cbd520144..655d5c2695e 100644 --- a/packages/astro/src/internal/create-clerk-instance.ts +++ b/packages/astro/src/internal/create-clerk-instance.ts @@ -111,10 +111,16 @@ 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. + * Returns undefined for headless variant (no UI needed). */ async function getClerkUiEntryChunk( options?: AstroClerkCreateInstanceParams, -): Promise { +): Promise { + // Skip UI loading for headless variant + if (options?.clerkJSVariant === 'headless') { + return undefined; + } + if (options?.clerkUiCtor) { return options.clerkUiCtor; } diff --git a/packages/astro/src/server/build-clerk-hotload-script.ts b/packages/astro/src/server/build-clerk-hotload-script.ts index b3cf7d37089..44a5a5c6b2c 100644 --- a/packages/astro/src/server/build-clerk-hotload-script.ts +++ b/packages/astro/src/server/build-clerk-hotload-script.ts @@ -10,21 +10,17 @@ function buildClerkHotloadScript(locals: APIContext['locals']) { const proxyUrl = getSafeEnv(locals).proxyUrl!; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const domain = getSafeEnv(locals).domain!; + const clerkJsVariant = getSafeEnv(locals).clerkJsVariant; const clerkJsScriptSrc = clerkJsScriptUrl({ clerkJSUrl: getSafeEnv(locals).clerkJsUrl, - clerkJSVariant: getSafeEnv(locals).clerkJsVariant, + clerkJSVariant: clerkJsVariant, clerkJSVersion: getSafeEnv(locals).clerkJsVersion, domain, proxyUrl, publishableKey, }); - const clerkUiScriptSrc = clerkUiScriptUrl({ - clerkUiUrl: getSafeEnv(locals).clerkUiUrl, - domain, - proxyUrl, - publishableKey, - }); - return ` + + const clerkJsScript = ` + >`; + + // Skip clerk-ui script for headless variant + if (clerkJsVariant === 'headless') { + return clerkJsScript + '\n'; + } + + const clerkUiScriptSrc = clerkUiScriptUrl({ + clerkUiUrl: getSafeEnv(locals).clerkUiUrl, + domain, + proxyUrl, + publishableKey, + }); + + const clerkUiScript = ` \n`; + >`; + + return clerkJsScript + clerkUiScript + '\n'; } export { buildClerkHotloadScript }; From 9a82f92a765e5a612025f5e3f4bfb4edfe06d0b4 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 9 Jan 2026 13:16:17 +0200 Subject: [PATCH 3/6] feat(react): skip clerk-ui loading for headless variant why: when using clerkJSVariant='headless', applications only need control components and don't require the full UI bundle. loading the unnecessary @clerk/ui script adds overhead without providing value. what changed: isomorphicClerk's getClerkUiEntryChunk method now returns undefined when clerkJSVariant === 'headless', skipping the loadClerkUiScript call entirely. users can now set clerkJSVariant='headless' to skip loading the ~100KB @clerk/ui bundle when using only control components. --- packages/react/src/isomorphicClerk.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index b39bf352b8c..1de5a4ee295 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -508,7 +508,12 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { return global.Clerk; } - private async getClerkUiEntryChunk(): Promise { + private async getClerkUiEntryChunk(): Promise { + // Skip UI loading for headless variant + if (this.options.clerkJSVariant === 'headless') { + return undefined; + } + if (this.options.clerkUiCtor) { return this.options.clerkUiCtor; } From d5f819a29dc2f01d87fa52d5cafb9bc88897d7c3 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 9 Jan 2026 13:16:28 +0200 Subject: [PATCH 4/6] feat(vue): skip clerk-ui loading for headless variant why: when using clerkJSVariant='headless', applications only need control components and don't require the full UI bundle. loading the unnecessary @clerk/ui script adds overhead without providing value. what changed: clerkPlugin now checks if clerkJSVariant === 'headless' and skips the loadClerkUiScript call, resolving the clerkUiCtorPromise to undefined instead. users can now set clerkJSVariant='headless' to skip loading the ~100KB @clerk/ui bundle when using only control components. --- packages/vue/src/plugin.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/vue/src/plugin.ts b/packages/vue/src/plugin.ts index 91a89ffe91d..f6ed9933884 100644 --- a/packages/vue/src/plugin.ts +++ b/packages/vue/src/plugin.ts @@ -78,15 +78,19 @@ export const clerkPlugin: Plugin<[PluginOptions]> = { void (async () => { try { const clerkPromise = loadClerkJsScript(options); - const clerkUiCtorPromise = pluginOptions.clerkUiCtor - ? Promise.resolve(pluginOptions.clerkUiCtor) - : (async () => { - await loadClerkUiScript(options); - if (!window.__internal_ClerkUiCtor) { - throw new Error('Failed to download latest Clerk UI. Contact support@clerk.com.'); - } - return window.__internal_ClerkUiCtor; - })(); + // Skip UI loading for headless variant + const clerkUiCtorPromise = + pluginOptions.clerkJSVariant === 'headless' + ? Promise.resolve(undefined) + : pluginOptions.clerkUiCtor + ? Promise.resolve(pluginOptions.clerkUiCtor) + : (async () => { + await loadClerkUiScript(options); + if (!window.__internal_ClerkUiCtor) { + throw new Error('Failed to download latest Clerk UI. Contact support@clerk.com.'); + } + return window.__internal_ClerkUiCtor; + })(); await clerkPromise; From a2e16cd1e7d8a08a7549a05eef2ddf366723d0f9 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Fri, 9 Jan 2026 13:16:39 +0200 Subject: [PATCH 5/6] test(e2e): add headless variant test for nextjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: verify that the headless variant correctly skips clerk-ui script injection across the full integration stack (env var → prop → script rendering). what changed: created headless-variant.test.ts that sets CLERK_JS_VARIANT='headless' and asserts clerk-ui script is absent while clerk-js script is present. --- integration/tests/headless-variant.test.ts | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 integration/tests/headless-variant.test.ts diff --git a/integration/tests/headless-variant.test.ts b/integration/tests/headless-variant.test.ts new file mode 100644 index 00000000000..e8861242029 --- /dev/null +++ b/integration/tests/headless-variant.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test'; + +import type { Application } from '../models/application'; +import { appConfigs } from '../presets'; + +test.describe('headless variant @nextjs', () => { + test.describe.configure({ mode: 'serial' }); + let app: Application; + + test.beforeAll(async () => { + app = await appConfigs.next.appRouter.clone().commit(); + await app.setup(); + // Use withEmailCodes but add the headless variant + const env = appConfigs.envs.withEmailCodes.clone().setEnvVariable('public', 'CLERK_JS_VARIANT', 'headless'); + await app.withEnv(env); + await app.dev(); + }); + + test.afterAll(async () => { + await app.teardown(); + }); + + test('does not inject clerk-ui script when headless variant is used', async ({ page }) => { + await page.goto(app.serverUrl); + + // Wait for clerk-js script to be present (ensures page has loaded) + await expect(page.locator('script[data-clerk-js-script]')).toBeAttached(); + + // clerk-ui script should NOT be present + await expect(page.locator('script[data-clerk-ui-script]')).not.toBeAttached(); + }); +}); From 294bc54d773e565c507c530d6f5784a6db00ff84 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Mon, 12 Jan 2026 12:47:14 +0200 Subject: [PATCH 6/6] dedupe --- .changeset/light-eagles-stay.md | 2 ++ pnpm-lock.yaml | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 .changeset/light-eagles-stay.md diff --git a/.changeset/light-eagles-stay.md b/.changeset/light-eagles-stay.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/light-eagles-stay.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57d54b6b39e..3424adde0e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2460,7 +2460,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==} @@ -14908,10 +14908,12 @@ packages: whatwg-encoding@2.0.0: resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} engines: {node: '>=12'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==}