Skip to content
Closed
2 changes: 2 additions & 0 deletions .changeset/light-eagles-stay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions integration/templates/next-app-router/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider
clerkJSVariant={process.env.NEXT_PUBLIC_CLERK_JS_VARIANT === 'headless' ? 'headless' : undefined}
appearance={{
options: {
showOptionalFields: true,
Expand Down
32 changes: 32 additions & 0 deletions integration/tests/headless-variant.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
8 changes: 7 additions & 1 deletion packages/astro/src/internal/create-clerk-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,16 @@ async function getClerkJsEntryChunk<TUi extends Ui = Ui>(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<TUi extends Ui = Ui>(
options?: AstroClerkCreateInstanceParams<TUi>,
): Promise<ClerkUiConstructor> {
): Promise<ClerkUiConstructor | undefined> {
// Skip UI loading for headless variant
if (options?.clerkJSVariant === 'headless') {
return undefined;
}

if (options?.clerkUiCtor) {
return options.clerkUiCtor;
}
Expand Down
32 changes: 22 additions & 10 deletions packages/astro/src/server/build-clerk-hotload-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,37 +10,49 @@ 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 = `
<script src="${clerkJsScriptSrc}"
data-clerk-js-script
async
crossOrigin='anonymous'
${publishableKey ? `data-clerk-publishable-key="${publishableKey}"` : ``}
${proxyUrl ? `data-clerk-proxy-url="${proxyUrl}"` : ``}
${domain ? `data-clerk-domain="${domain}"` : ``}
></script>
></script>`;

// 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 = `
<script src="${clerkUiScriptSrc}"
data-clerk-ui-script
async
crossOrigin='anonymous'
${publishableKey ? `data-clerk-publishable-key="${publishableKey}"` : ``}
${proxyUrl ? `data-clerk-proxy-url="${proxyUrl}"` : ``}
${domain ? `data-clerk-domain="${domain}"` : ``}
></script>\n`;
></script>`;

return clerkJsScript + clerkUiScript + '\n';
}

export { buildClerkHotloadScript };
14 changes: 8 additions & 6 deletions packages/nextjs/src/utils/clerk-script.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,14 @@ export function ClerkScripts({ router }: { router: ClerkScriptProps['router'] })
dataAttribute='data-clerk-js-script'
router={router}
/>
<ClerkScript
scriptUrl={clerkUiScriptUrl(opts)}
attributes={buildClerkUiScriptAttributes(opts)}
dataAttribute='data-clerk-ui-script'
router={router}
/>
{clerkJSVariant !== 'headless' && (
<ClerkScript
scriptUrl={clerkUiScriptUrl(opts)}
attributes={buildClerkUiScriptAttributes(opts)}
dataAttribute='data-clerk-ui-script'
router={router}
/>
)}
</>
);
}
9 changes: 7 additions & 2 deletions packages/react/src/isomorphicClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
}

try {
const clerkUiCtor = this.getClerkUiEntryChunk();
const clerkUiCtor = await this.getClerkUiEntryChunk();
const clerk = await this.getClerkJsEntryChunk();

if (!clerk.loaded) {
Expand Down Expand Up @@ -508,7 +508,12 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
return global.Clerk;
}

private async getClerkUiEntryChunk(): Promise<ClerkUiConstructor> {
private async getClerkUiEntryChunk(): Promise<ClerkUiConstructor | undefined> {
// Skip UI loading for headless variant
if (this.options.clerkJSVariant === 'headless') {
return undefined;
}

if (this.options.clerkUiCtor) {
return this.options.clerkUiCtor;
}
Expand Down
22 changes: 13 additions & 9 deletions packages/vue/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
4 changes: 3 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading