Skip to content

Commit c77ca7e

Browse files
brkalowclaude
andauthored
feat(react): Add env variable fallback for publishableKey in Vite apps (#7634)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8d91225 commit c77ca7e

7 files changed

Lines changed: 187 additions & 17 deletions

File tree

.changeset/warm-keys-glow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/react': minor
3+
---
4+
5+
Add automatic environment variable fallback for Vite applications. When `publishableKey` is not explicitly provided to `ClerkProvider`, the SDK now checks for `VITE_CLERK_PUBLISHABLE_KEY` and `CLERK_PUBLISHABLE_KEY` environment variables.

integration/templates/custom-flows-react-vite/src/main.tsx

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,11 @@ import { SignUp } from './routes/SignUp';
99
import { Protected } from './routes/Protected';
1010
import { Waitlist } from './routes/Waitlist';
1111

12-
// Import your Publishable Key
13-
const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
14-
15-
if (!PUBLISHABLE_KEY) {
16-
throw new Error('Add your Clerk Publishable Key to the .env file');
17-
}
18-
1912
createRoot(document.getElementById('root')!).render(
2013
<StrictMode>
2114
<div className='bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10'>
2215
<div className='flex w-full max-w-sm flex-col gap-6'>
2316
<ClerkProvider
24-
publishableKey={PUBLISHABLE_KEY}
2517
clerkJSUrl={import.meta.env.VITE_CLERK_JS_URL as string}
2618
clerkUiUrl={import.meta.env.VITE_CLERK_UI_URL as string}
2719
appearance={{

integration/templates/react-vite/src/main.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@ const Root = () => {
2828
const navigate = useNavigate();
2929
return (
3030
<ClerkProvider
31-
// @ts-ignore
32-
publishableKey={import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string}
3331
clerkJSUrl={import.meta.env.VITE_CLERK_JS_URL as string}
3432
clerkUiUrl={import.meta.env.VITE_CLERK_UI_URL as string}
3533
routerPush={(to: string) => navigate(to)}

packages/react/src/contexts/ClerkContextProvider.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import React from 'react';
1111

1212
import { IsomorphicClerk } from '../isomorphicClerk';
1313
import type { IsomorphicClerkOptions } from '../types';
14+
import { mergeWithEnv } from '../utils';
1415
import { AuthContext } from './AuthContext';
1516
import { IsomorphicClerkContext } from './IsomorphicClerkContext';
1617

@@ -24,7 +25,9 @@ export type ClerkContextProviderState = Resources;
2425

2526
export function ClerkContextProvider(props: ClerkContextProvider) {
2627
const { isomorphicClerkOptions, initialState, children } = props;
27-
const { isomorphicClerk: clerk, clerkStatus } = useLoadedIsomorphicClerk(isomorphicClerkOptions);
28+
// Merge options with environment variable fallbacks (supports Vite's VITE_CLERK_* env vars)
29+
const mergedOptions = mergeWithEnv(isomorphicClerkOptions);
30+
const { isomorphicClerk: clerk, clerkStatus } = useLoadedIsomorphicClerk(mergedOptions);
2831

2932
const [state, setState] = React.useState<ClerkContextProviderState>({
3033
client: clerk.client as ClientResource,
@@ -111,17 +114,17 @@ export function ClerkContextProvider(props: ClerkContextProvider) {
111114
);
112115
}
113116

114-
const useLoadedIsomorphicClerk = (options: IsomorphicClerkOptions) => {
115-
const isomorphicClerkRef = React.useRef(IsomorphicClerk.getOrCreateInstance(options));
117+
const useLoadedIsomorphicClerk = (mergedOptions: IsomorphicClerkOptions) => {
118+
const isomorphicClerkRef = React.useRef(IsomorphicClerk.getOrCreateInstance(mergedOptions));
116119
const [clerkStatus, setClerkStatus] = React.useState(isomorphicClerkRef.current.status);
117120

118121
React.useEffect(() => {
119-
void isomorphicClerkRef.current.__internal_updateProps({ appearance: options.appearance });
120-
}, [options.appearance]);
122+
void isomorphicClerkRef.current.__internal_updateProps({ appearance: mergedOptions.appearance });
123+
}, [mergedOptions.appearance]);
121124

122125
React.useEffect(() => {
123-
void isomorphicClerkRef.current.__internal_updateProps({ options });
124-
}, [options.localization]);
126+
void isomorphicClerkRef.current.__internal_updateProps({ options: mergedOptions });
127+
}, [mergedOptions.localization]);
125128

126129
React.useEffect(() => {
127130
isomorphicClerkRef.current.on('status', setClerkStatus);
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import * as getEnvVariableModule from '@clerk/shared/getEnvVariable';
2+
import type { IsomorphicClerkOptions } from '@clerk/shared/types';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
import { mergeWithEnv } from '../envVariables';
6+
7+
// Mock getEnvVariable to control env var behavior in tests
8+
vi.mock('@clerk/shared/getEnvVariable', () => ({
9+
getEnvVariable: vi.fn(() => ''),
10+
}));
11+
12+
describe('mergeWithEnv', () => {
13+
const mockedGetEnvVariable = vi.mocked(getEnvVariableModule.getEnvVariable);
14+
15+
beforeEach(() => {
16+
vi.clearAllMocks();
17+
});
18+
19+
it('returns passed-in publishableKey when provided', () => {
20+
mockedGetEnvVariable.mockReturnValue('should_not_be_used');
21+
22+
const options: IsomorphicClerkOptions = {
23+
publishableKey: 'pk_test_explicit',
24+
};
25+
26+
const result = mergeWithEnv(options);
27+
28+
expect(result.publishableKey).toBe('pk_test_explicit');
29+
});
30+
31+
it('falls back to VITE_CLERK_PUBLISHABLE_KEY env var when option is undefined', () => {
32+
mockedGetEnvVariable.mockImplementation((name: string) => {
33+
if (name === 'VITE_CLERK_PUBLISHABLE_KEY') {
34+
return 'pk_test_vite';
35+
}
36+
return '';
37+
});
38+
39+
const result = mergeWithEnv({} as any);
40+
41+
expect(result.publishableKey).toBe('pk_test_vite');
42+
});
43+
44+
it('falls back to CLERK_PUBLISHABLE_KEY when VITE_ prefixed not set', () => {
45+
mockedGetEnvVariable.mockImplementation((name: string) => {
46+
if (name === 'CLERK_PUBLISHABLE_KEY') {
47+
return 'pk_test_node';
48+
}
49+
return '';
50+
});
51+
52+
const result = mergeWithEnv({} as any);
53+
54+
expect(result.publishableKey).toBe('pk_test_node');
55+
});
56+
57+
it('prioritizes VITE_ prefixed env var over non-prefixed', () => {
58+
mockedGetEnvVariable.mockImplementation((name: string) => {
59+
const envVars: Record<string, string> = {
60+
VITE_CLERK_PUBLISHABLE_KEY: 'pk_test_vite',
61+
CLERK_PUBLISHABLE_KEY: 'pk_test_node',
62+
};
63+
return envVars[name] || '';
64+
});
65+
66+
const result = mergeWithEnv({} as any);
67+
68+
expect(result.publishableKey).toBe('pk_test_vite');
69+
});
70+
71+
it('does NOT fall back when publishableKey is empty string (framework SDK behavior)', () => {
72+
mockedGetEnvVariable.mockReturnValue('pk_test_vite');
73+
74+
const result = mergeWithEnv({
75+
publishableKey: '',
76+
});
77+
78+
// Should preserve empty string, not fall back to env var
79+
expect(result.publishableKey).toBe('');
80+
});
81+
82+
it('returns undefined publishableKey when neither option nor env var is set', () => {
83+
mockedGetEnvVariable.mockReturnValue('');
84+
85+
const result = mergeWithEnv({} as any);
86+
87+
// When env var is not set, we don't add the property
88+
expect(result.publishableKey).toBeUndefined();
89+
});
90+
91+
it('preserves other options that are not env-var backed', () => {
92+
mockedGetEnvVariable.mockReturnValue('');
93+
94+
const options: IsomorphicClerkOptions = {
95+
publishableKey: 'pk_test',
96+
appearance: { variables: { colorPrimary: 'red' } },
97+
localization: { signIn: { start: { title: 'Hello' } } },
98+
signInUrl: '/custom-sign-in',
99+
signUpUrl: '/custom-sign-up',
100+
};
101+
102+
const result = mergeWithEnv(options);
103+
104+
expect(result.publishableKey).toBe('pk_test');
105+
expect(result.appearance).toEqual({ variables: { colorPrimary: 'red' } });
106+
expect(result.localization).toEqual({ signIn: { start: { title: 'Hello' } } });
107+
expect(result.signInUrl).toBe('/custom-sign-in');
108+
expect(result.signUpUrl).toBe('/custom-sign-up');
109+
});
110+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { getEnvVariable } from '@clerk/shared/getEnvVariable';
2+
3+
import type { IsomorphicClerkOptions } from '../types';
4+
5+
/**
6+
* Gets an environment variable value, checking for Vite's VITE_ prefix first.
7+
* This allows React SDK users with Vite to use VITE_CLERK_* env vars
8+
* (which Vite exposes client-side) without manual configuration.
9+
*
10+
* Note: Empty string values are treated as "not set" and will fall through to
11+
* the next env var in the chain. This is intentional since empty values are
12+
* typically invalid for these options.
13+
*
14+
* @param name - The environment variable name without prefix (e.g., 'CLERK_PUBLISHABLE_KEY')
15+
* @returns The value of the environment variable, or empty string if not found
16+
*/
17+
const getEnvVar = (name: string): string => {
18+
// Check for Vite-prefixed env var first (client-side exposed)
19+
// Then fall back to unprefixed version (for SSR, Node.js, etc.)
20+
// Note: Uses || so empty string falls through to the next check
21+
return getEnvVariable(`VITE_${name}`) || getEnvVariable(name);
22+
};
23+
24+
/**
25+
* Helper to get env fallback only when the option is undefined.
26+
* We check for undefined specifically (not falsy) to avoid conflicting with framework SDKs
27+
* that may pass an empty string when their env var is not set.
28+
*
29+
* Returns the env var value only if it's non-empty, otherwise returns undefined
30+
* to preserve the original behavior when no env var is set.
31+
*/
32+
const withEnvFallback = (value: string | undefined, envVarName: string): string | undefined => {
33+
if (value !== undefined) {
34+
return value;
35+
}
36+
const envValue = getEnvVar(envVarName);
37+
return envValue || undefined;
38+
};
39+
40+
/**
41+
* Merges ClerkProvider options with environment variable fallbacks.
42+
* This supports Vite users who set VITE_CLERK_* or CLERK_* env vars.
43+
* Passed-in options always take priority over environment variables.
44+
*
45+
* Supported environment variables:
46+
* - VITE_CLERK_PUBLISHABLE_KEY / CLERK_PUBLISHABLE_KEY
47+
*
48+
* @param options - The options passed to ClerkProvider
49+
* @returns Options with environment variable fallbacks applied
50+
*/
51+
export const mergeWithEnv = (options: IsomorphicClerkOptions): IsomorphicClerkOptions => {
52+
// Get env fallback values (undefined if not set)
53+
const publishableKey = withEnvFallback(options.publishableKey, 'CLERK_PUBLISHABLE_KEY');
54+
55+
// Only add publishableKey to result if it has a defined value
56+
// URL fallbacks removed due to compatibility issues with @clerk/react-router
57+
return {
58+
...options,
59+
...(publishableKey !== undefined && { publishableKey }),
60+
};
61+
};

packages/react/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './childrenUtils';
2+
export * from './envVariables';
23
export * from './isConstructor';
34
export * from './useMaxAllowedInstancesGuard';
45
export * from './useCustomElementPortal';

0 commit comments

Comments
 (0)