diff --git a/.changeset/expo-native-components-moved.md b/.changeset/expo-native-components-moved.md
new file mode 100644
index 00000000000..1bbd9ff4a93
--- /dev/null
+++ b/.changeset/expo-native-components-moved.md
@@ -0,0 +1,27 @@
+---
+'@clerk/expo': minor
+---
+
+The native components (`AuthView`, `UserButton`, `UserProfileView`) and native client sync have moved to the new `@clerk/expo-native-components` package. Apps that don't install it no longer include the Clerk iOS and Android SDKs and no longer require iOS 17.
+
+If you use the native components, install the new package:
+
+```sh
+npx expo install @clerk/expo-native-components
+```
+
+add its config plugin alongside `@clerk/expo` in your app config:
+
+```json
+{
+ "expo": {
+ "plugins": ["@clerk/expo", "@clerk/expo-native-components"]
+ }
+}
+```
+
+then rebuild your native app. `@clerk/expo/native` keeps working and re-exports the components from `@clerk/expo-native-components`, which you can also import from directly. Without `@clerk/expo-native-components` installed, rendering a component from `@clerk/expo/native` throws an error explaining how to install it.
+
+`useBiometricCredentials().reverify()` also runs through Clerk's native SDK and now requires `@clerk/expo-native-components`. Enrollment and sign-in only need `@clerk/expo-biometrics`.
+
+The `keychainService` and `theme` config plugin options now belong to the `@clerk/expo-native-components` plugin. The `@clerk/expo` plugin forwards them when `@clerk/expo-native-components` is installed, and warns otherwise.
diff --git a/.changeset/introduce-expo-native.md b/.changeset/introduce-expo-native.md
new file mode 100644
index 00000000000..a7a0839fb61
--- /dev/null
+++ b/.changeset/introduce-expo-native.md
@@ -0,0 +1,5 @@
+---
+'@clerk/expo-native-components': minor
+---
+
+Add `@clerk/expo-native-components`, the optional companion package to `@clerk/expo` that contains Clerk's prebuilt native components (`AuthView`, `UserButton`, `UserProfileView`) and the native module built on the Clerk iOS and Android SDKs. It includes a config plugin that accepts the `keychainService` and `theme` options.
diff --git a/.claude/skills/clerk-monorepo/references/package-map.md b/.claude/skills/clerk-monorepo/references/package-map.md
index c5cabf3765b..47abcecfa41 100644
--- a/.claude/skills/clerk-monorepo/references/package-map.md
+++ b/.claude/skills/clerk-monorepo/references/package-map.md
@@ -16,32 +16,33 @@ browser directly), **adapter** (framework SDK), **ui/i18n**, **tooling**. The "B
two packages whose runtime is pushed into apps pinned to older SDKs, so they carry the strict
backwards-compatibility contract (see `breaking-changes.md`).
-| Package | Category | BC | Purpose |
-| ----------------------------- | --------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `@clerk/shared` | foundational | | Internal utilities used by all SDKs (storage, events, React helpers). Hosts the shared types as `@clerk/shared/types`. Most-depended-on package. |
-| `@clerk/backend` | foundational | | Backend API REST client, JWT verification, webhook helpers. Used by every server adapter. |
-| `@clerk/clerk-js` | browser-runtime | ⚠️ | The browser runtime (script tag). Backwards-compat sensitive. |
-| `@clerk/ui` | ui | ⚠️ | React components for the hosted sign-in / sign-up flows (`packages/ui/src/components`). Consumed by the react/astro/vue/chrome-extension adapters. Backwards-compat sensitive. |
-| `@clerk/mosaic` | ui | | Experimental next-generation React components. Public ESM package (`UserButton` + `styles.css`). Ships unstyled, accessible primitives (dialog, menu, popover, ...) internally under `src/primitives/`. Reads Clerk context from the host SDK via `@clerk/shared`. |
-| `@clerk/react` | adapter (core) | | React hooks and context (`useAuth`, `useUser`, `useOrganization`, ...). Shared by the React-based adapters. |
-| `@clerk/nextjs` | adapter | | Next.js SDK: middleware, route handlers, server components. |
-| `@clerk/express` | adapter | | Express middleware and server helpers. |
-| `@clerk/fastify` | adapter | | Fastify plugin. |
-| `@clerk/hono` | adapter | | Hono SDK (edge / serverless). |
-| `@clerk/astro` | adapter | | Astro integration (components + server utilities). |
-| `@clerk/nuxt` | adapter | | Nuxt module (Vue). |
-| `@clerk/vue` | adapter | | Vue 3 composables and components. |
-| `@clerk/react-router` | adapter | | React Router v7 SDK. |
-| `@clerk/tanstack-react-start` | adapter | | TanStack React Start SDK. |
-| `@clerk/expo` | adapter | | React Native / Expo SDK. |
-| `@clerk/expo-passkeys` | adapter | | Passkeys companion library for Expo. |
-| `@clerk/chrome-extension` | browser-runtime | | SDK for Chrome extension contexts. |
-| `@clerk/localizations` | ui/i18n | | Translation strings for the UI components. Consumed by `ui`. |
-| `@clerk/testing` | tooling | | E2E test helpers for consumers (Playwright + Cypress). |
-| `@clerk/msw` | tooling | | MSW request handlers for mocking the Clerk API in tests. Private (not published). |
-| `@clerk/swingset` | tooling | | Component explorer for `@clerk/mosaic`. Private (not published). |
-| `@clerk/upgrade` | tooling | | CLI codemod tool for upgrading consumers between SDK versions. |
-| `@clerk/eslint-plugin` | tooling | | ESLint plugin enforcing Clerk patterns across JavaScript frameworks (lint rules shipped to apps). Published. |
+| Package | Category | BC | Purpose |
+| ------------------------------- | --------------- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `@clerk/shared` | foundational | | Internal utilities used by all SDKs (storage, events, React helpers). Hosts the shared types as `@clerk/shared/types`. Most-depended-on package. |
+| `@clerk/backend` | foundational | | Backend API REST client, JWT verification, webhook helpers. Used by every server adapter. |
+| `@clerk/clerk-js` | browser-runtime | ⚠️ | The browser runtime (script tag). Backwards-compat sensitive. |
+| `@clerk/ui` | ui | ⚠️ | React components for the hosted sign-in / sign-up flows (`packages/ui/src/components`). Consumed by the react/astro/vue/chrome-extension adapters. Backwards-compat sensitive. |
+| `@clerk/mosaic` | ui | | Experimental next-generation React components. Public ESM package (`UserButton` + `styles.css`). Ships unstyled, accessible primitives (dialog, menu, popover, ...) internally under `src/primitives/`. Reads Clerk context from the host SDK via `@clerk/shared`. |
+| `@clerk/react` | adapter (core) | | React hooks and context (`useAuth`, `useUser`, `useOrganization`, ...). Shared by the React-based adapters. |
+| `@clerk/nextjs` | adapter | | Next.js SDK: middleware, route handlers, server components. |
+| `@clerk/express` | adapter | | Express middleware and server helpers. |
+| `@clerk/fastify` | adapter | | Fastify plugin. |
+| `@clerk/hono` | adapter | | Hono SDK (edge / serverless). |
+| `@clerk/astro` | adapter | | Astro integration (components + server utilities). |
+| `@clerk/nuxt` | adapter | | Nuxt module (Vue). |
+| `@clerk/vue` | adapter | | Vue 3 composables and components. |
+| `@clerk/react-router` | adapter | | React Router v7 SDK. |
+| `@clerk/tanstack-react-start` | adapter | | TanStack React Start SDK. |
+| `@clerk/expo` | adapter | | React Native / Expo SDK. |
+| `@clerk/expo-native-components` | adapter | | Native components (`AuthView`, `UserButton`, `UserProfileView`) and the native module for Expo, built on clerk-ios / clerk-android. Optional companion to `@clerk/expo`. |
+| `@clerk/expo-passkeys` | adapter | | Passkeys companion library for Expo. |
+| `@clerk/chrome-extension` | browser-runtime | | SDK for Chrome extension contexts. |
+| `@clerk/localizations` | ui/i18n | | Translation strings for the UI components. Consumed by `ui`. |
+| `@clerk/testing` | tooling | | E2E test helpers for consumers (Playwright + Cypress). |
+| `@clerk/msw` | tooling | | MSW request handlers for mocking the Clerk API in tests. Private (not published). |
+| `@clerk/swingset` | tooling | | Component explorer for `@clerk/mosaic`. Private (not published). |
+| `@clerk/upgrade` | tooling | | CLI codemod tool for upgrading consumers between SDK versions. |
+| `@clerk/eslint-plugin` | tooling | | ESLint plugin enforcing Clerk patterns across JavaScript frameworks (lint rules shipped to apps). Published. |
Tests: `pnpm turbo test --filter=@clerk/` (or `pnpm --filter @clerk/ test` after a
build). Most packages use vitest; `@clerk/backend` runs a multi-runtime suite (node + edge +
diff --git a/.github/labeler.yml b/.github/labeler.yml
index b6f18eeb545..d07fbb87c3f 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -28,6 +28,10 @@ expo:
- changed-files:
- any-glob-to-any-file: packages/expo/**
+expo-native-components:
+ - changed-files:
+ - any-glob-to-any-file: packages/expo-native-components/**
+
express:
- changed-files:
- any-glob-to-any-file: packages/express/**
diff --git a/.github/workflows/api-changes.yml b/.github/workflows/api-changes.yml
index 25971a1ad1b..9feadb28f55 100644
--- a/.github/workflows/api-changes.yml
+++ b/.github/workflows/api-changes.yml
@@ -19,6 +19,7 @@ on:
- 'packages/clerk-js/**'
- 'packages/expo/**'
- 'packages/expo-google-signin/**'
+ - 'packages/expo-native-components/**'
- 'packages/expo-passkeys/**'
- 'packages/express/**'
- 'packages/fastify/**'
@@ -56,6 +57,7 @@ env:
--filter=@clerk/clerk-js
--filter=@clerk/expo
--filter=@clerk/expo-google-signin
+ --filter=@clerk/expo-native-components
--filter=@clerk/expo-passkeys
--filter=@clerk/express
--filter=@clerk/fastify
diff --git a/.github/workflows/expo-native-build.yml b/.github/workflows/expo-native-build.yml
index 9b744deb62f..aad2a09afa6 100644
--- a/.github/workflows/expo-native-build.yml
+++ b/.github/workflows/expo-native-build.yml
@@ -12,6 +12,7 @@ on:
- 'packages/expo/**'
- 'packages/expo-biometrics/**'
- 'packages/expo-google-signin/**'
+ - 'packages/expo-native-components/**'
workflow_dispatch:
permissions:
@@ -87,6 +88,7 @@ jobs:
packages/expo \
packages/expo-biometrics \
packages/expo-google-signin \
+ packages/expo-native-components \
packages/react \
packages/shared \
"$FIXTURE_DIR" |
@@ -127,6 +129,7 @@ jobs:
mkdir -p "$SDK_PACK_DIR"
pnpm --filter @clerk/expo pack --pack-destination "$SDK_PACK_DIR"
pnpm --filter @clerk/expo-google-signin pack --pack-destination "$SDK_PACK_DIR"
+ pnpm --filter @clerk/expo-native-components pack --pack-destination "$SDK_PACK_DIR"
pnpm --filter @clerk/expo-biometrics pack --pack-destination "$SDK_PACK_DIR"
- name: Install fixture dependencies
@@ -138,11 +141,12 @@ jobs:
run: |
cp "package.sdk-$EXPO_SDK.json" package.json
pnpm install --no-frozen-lockfile
- # [0-9] keeps this glob off the clerk-expo-google-signin tarball.
+ # [0-9] keeps this glob off the other clerk-expo-* tarballs.
SDK_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-[0-9]*.tgz)"
GOOGLE_SIGNIN_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-google-signin-*.tgz)"
+ NATIVE_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-native-components-*.tgz)"
BIOMETRICS_TARBALL="$(ls "$SDK_PACK_DIR"/clerk-expo-biometrics-*.tgz)"
- pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" "$BIOMETRICS_TARBALL" -w
+ pnpm add "$SDK_TARBALL" "$GOOGLE_SIGNIN_TARBALL" "$NATIVE_TARBALL" "$BIOMETRICS_TARBALL" -w
# expo-dev-client makes even release builds boot into the dev
# launcher (unreachable Metro in CI), which stalls every Maestro
# flow on a blank screen. Skip it on e2e jobs only.
diff --git a/integration/templates/expo-native/app.json b/integration/templates/expo-native/app.json
index 58e258ea523..4ea4b82bb8f 100644
--- a/integration/templates/expo-native/app.json
+++ b/integration/templates/expo-native/app.json
@@ -12,6 +12,6 @@
"android": {
"package": "com.clerk.exponativebuildfixture"
},
- "plugins": ["expo-secure-store", "@clerk/expo", "expo-web-browser"]
+ "plugins": ["expo-secure-store", "@clerk/expo", "@clerk/expo-native", "expo-web-browser"]
}
}
diff --git a/package.json b/package.json
index 950eda61070..723fa084925 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
"changeset": "changeset",
"changeset:empty": "pnpm changeset --empty",
"clean": "turbo run clean",
- "dev": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/* --filter=!@clerk/expo --filter=!@clerk/tanstack-react-start --filter=!@clerk/chrome-extension",
+ "dev": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/* --filter=!@clerk/expo --filter=!@clerk/expo-native-components --filter=!@clerk/tanstack-react-start --filter=!@clerk/chrome-extension",
"dev:fe-libs": "TURBO_UI=0 FORCE_COLOR=1 turbo dev --filter=@clerk/clerk-js --filter=@clerk/ui --filter=@clerk/shared",
"dev:js": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:current --filter=@clerk/clerk-js",
"dev:sandbox": "TURBO_UI=0 FORCE_COLOR=1 turbo dev:sandbox:serve",
diff --git a/packages/expo/.gitignore b/packages/expo-native-components/.gitignore
similarity index 100%
rename from packages/expo/.gitignore
rename to packages/expo-native-components/.gitignore
diff --git a/packages/expo-native-components/LICENSE b/packages/expo-native-components/LICENSE
new file mode 100644
index 00000000000..66914b6af7c
--- /dev/null
+++ b/packages/expo-native-components/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 Clerk, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/expo-native-components/README.md b/packages/expo-native-components/README.md
new file mode 100644
index 00000000000..dceac483ebb
--- /dev/null
+++ b/packages/expo-native-components/README.md
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
@clerk/expo-native-components
+
+
+
+
+[](https://clerk.com/discord)
+[](https://clerk.com/docs?utm_source=github&utm_medium=clerk_expo_native_components)
+[](https://x.com/intent/follow?screen_name=clerk)
+
+[Changelog](https://github.com/clerk/javascript/blob/main/packages/expo-native-components/CHANGELOG.md)
+·
+[Report a Bug](https://github.com/clerk/javascript/issues/new?assignees=&labels=needs-triage&projects=&template=BUG_REPORT.yml)
+·
+[Request a Feature](https://feedback.clerk.com/roadmap)
+·
+[Get help](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_expo_native_components)
+
+
+
+## Getting Started
+
+`@clerk/expo-native-components` adds Clerk's prebuilt native components to an Expo app that uses [`@clerk/expo`](https://github.com/clerk/javascript/tree/main/packages/expo). It contains the native modules built on the Clerk iOS and Android SDKs:
+
+- **iOS**: [clerk-ios](https://github.com/clerk/clerk-ios) (SwiftUI)
+- **Android**: [clerk-android](https://github.com/clerk/clerk-android) (Jetpack Compose)
+
+Install it only if you use `AuthView`, `UserProfileView`, `UserButton`, or biometric credentials. Apps that only use `@clerk/expo` do not include the Clerk native SDKs.
+
+### Prerequisites
+
+- `@clerk/expo`
+- Expo 54 or later, with a [development build](https://docs.expo.dev/develop/development-builds/introduction/) (Expo Go is not supported)
+- iOS 17 or later
+
+### Installation
+
+```sh
+npx expo install @clerk/expo @clerk/expo-native-components
+```
+
+Add the config plugin alongside `@clerk/expo` in your app config, then rebuild your native app:
+
+```json
+{
+ "expo": {
+ "plugins": ["@clerk/expo", "@clerk/expo-native-components"]
+ }
+}
+```
+
+The plugin accepts the following options:
+
+- `keychainService`: keychain service identifier to share the session with app extensions.
+- `theme`: path to a JSON file with `colors`, `darkColors`, and `design` keys that styles the native components.
+
+## Usage
+
+```tsx
+import { useAuth } from '@clerk/expo';
+import { AuthView } from '@clerk/expo-native-components';
+
+export default function SignInScreen() {
+ const { isSignedIn } = useAuth();
+
+ if (isSignedIn) {
+ return null;
+ }
+
+ return ;
+}
+```
+
+For further information, guides, and examples visit the [Expo reference documentation](https://clerk.com/docs/references/expo/overview?utm_source=github&utm_medium=clerk_expo_native_components).
+
+## Support
+
+For help, visit our [support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_expo_native_components).
+
+## Community
+
+Join our [Discord community](https://clerk.com/discord) to connect with other developers.
+
+## Contributing
+
+We're open to all community contributions! If you'd like to contribute in any way, please read [our contribution guidelines](https://github.com/clerk/javascript/blob/main/docs/CONTRIBUTING.md) and [code of conduct](https://github.com/clerk/javascript/blob/main/docs/CODE_OF_CONDUCT.md).
+
+## Security
+
+`@clerk/expo-native-components` follows good practices of security, but 100% security cannot be assured.
+
+`@clerk/expo-native-components` is provided **"as is"** without any **warranty**. Use at your own risk.
+
+_For more information and to report security issues, please refer to our [security documentation](https://github.com/clerk/javascript/blob/main/docs/SECURITY.md)._
+
+## License
+
+This project is licensed under the **MIT license**.
+
+See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/expo-native-components/LICENSE) for more information.
diff --git a/packages/expo/android/build.gradle b/packages/expo-native-components/android/build.gradle
similarity index 93%
rename from packages/expo/android/build.gradle
rename to packages/expo-native-components/android/build.gradle
index 8975e848f7d..facf8a66952 100644
--- a/packages/expo/android/build.gradle
+++ b/packages/expo-native-components/android/build.gradle
@@ -13,8 +13,9 @@ applyKotlinExpoModulesCorePlugin()
group = 'com.clerk.expo'
version = '1.0.0'
-def clerkExpoPackageJson = new JsonSlurper().parse(new File(projectDir, "../package.json"))
-def clerkExpoVersion = clerkExpoPackageJson.version.toString()
+// Set by the @clerk/expo-native-components config plugin to the installed @clerk/expo version.
+def clerkExpoVersion = rootProject.findProperty("clerkExpo.hostSdkVersion")?.toString() ?:
+ new JsonSlurper().parse(new File(projectDir, "../package.json")).version.toString()
// Dependency versions - centralized for easier updates
// See: https://docs.gradle.org/current/userguide/version_catalogs.html for app-level version catalogs
@@ -97,7 +98,7 @@ try {
}
} catch (Exception ignored) {
// Future Gradle versions with Isolated Projects may block cross-project configuration.
- // In that case, users should add '@clerk/expo' to their app.json plugins array instead,
+ // In that case, users should add '@clerk/expo-native-components' to their app.json plugins array instead,
// which applies the flag via the config plugin's withClerkAndroid.
}
diff --git a/packages/expo/android/src/main/AndroidManifest.xml b/packages/expo-native-components/android/src/main/AndroidManifest.xml
similarity index 100%
rename from packages/expo/android/src/main/AndroidManifest.xml
rename to packages/expo-native-components/android/src/main/AndroidManifest.xml
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt
similarity index 100%
rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt
rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt
similarity index 100%
rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt
rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
similarity index 100%
rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt
similarity index 100%
rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt
rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt
similarity index 100%
rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt
rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt
similarity index 100%
rename from packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt
rename to packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt
diff --git a/packages/expo/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt b/packages/expo-native-components/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt
similarity index 100%
rename from packages/expo/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt
rename to packages/expo-native-components/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt
diff --git a/packages/expo/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt b/packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt
similarity index 100%
rename from packages/expo/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt
rename to packages/expo-native-components/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt
diff --git a/packages/expo-native-components/app.plugin.d.ts b/packages/expo-native-components/app.plugin.d.ts
new file mode 100644
index 00000000000..8808449dd73
--- /dev/null
+++ b/packages/expo-native-components/app.plugin.d.ts
@@ -0,0 +1,5 @@
+import type { ConfigPlugin } from '@expo/config-plugins';
+
+declare const withClerkExpoNativeComponents: ConfigPlugin<{ keychainService?: string; theme?: string } | void>;
+
+export = withClerkExpoNativeComponents;
diff --git a/packages/expo-native-components/app.plugin.js b/packages/expo-native-components/app.plugin.js
new file mode 100644
index 00000000000..36e10092b7a
--- /dev/null
+++ b/packages/expo-native-components/app.plugin.js
@@ -0,0 +1,333 @@
+/**
+ * Expo config plugin for @clerk/expo-native-components
+ * Configures iOS and Android for the Clerk native SDKs (clerk-ios / clerk-android)
+ *
+ * When this plugin is used:
+ * 1. iOS is configured with the required deployment target and metadata
+ * 2. Android is configured with packaging exclusions for dependencies
+ *
+ * Native modules and views are registered via Expo Modules autolinking.
+ */
+const {
+ createRunOncePlugin,
+ withXcodeProject,
+ withDangerousMod,
+ withInfoPlist,
+ withAppBuildGradle,
+ withGradleProperties,
+} = require('@expo/config-plugins');
+const path = require('path');
+const fs = require('fs');
+const packageJson = require('./package.json');
+
+const CLERK_MIN_IOS_VERSION = '17.0';
+const HOST_SDK_VERSION_GRADLE_PROPERTY = 'clerkExpo.hostSdkVersion';
+
+const resolveClerkExpo = projectRoot => {
+ try {
+ const paths = [projectRoot, process.cwd()].filter(Boolean);
+ const clerkExpoPackageJsonPath = require.resolve('@clerk/expo/package.json', { paths });
+ return {
+ dir: path.dirname(clerkExpoPackageJsonPath),
+ version: require(clerkExpoPackageJsonPath).version,
+ };
+ } catch {
+ return null;
+ }
+};
+
+const withClerkIOS = (config, hostSdkVersion) => {
+ console.log('✅ Clerk iOS plugin loaded');
+
+ // IMPORTANT: Set iOS deployment target in Podfile.properties.json BEFORE pod install
+ // This ensures ClerkExpo pod gets installed (it requires iOS 17.0)
+ config = withDangerousMod(config, [
+ 'ios',
+ async config => {
+ const podfilePropertiesPath = path.join(config.modRequest.platformProjectRoot, 'Podfile.properties.json');
+
+ let properties = {};
+ if (fs.existsSync(podfilePropertiesPath)) {
+ try {
+ properties = JSON.parse(fs.readFileSync(podfilePropertiesPath, 'utf8'));
+ } catch {
+ // If file exists but is invalid JSON, start fresh
+ }
+ }
+
+ // Set the iOS deployment target
+ if (
+ !properties['ios.deploymentTarget'] ||
+ parseFloat(properties['ios.deploymentTarget']) < parseFloat(CLERK_MIN_IOS_VERSION)
+ ) {
+ properties['ios.deploymentTarget'] = CLERK_MIN_IOS_VERSION;
+ fs.writeFileSync(podfilePropertiesPath, JSON.stringify(properties, null, 2) + '\n');
+ console.log(`✅ Set ios.deploymentTarget to ${CLERK_MIN_IOS_VERSION} in Podfile.properties.json`);
+ }
+
+ return config;
+ },
+ ]);
+
+ // First update the iOS deployment target to 17.0 (required by Clerk iOS SDK)
+ config = withXcodeProject(config, config => {
+ const xcodeProject = config.modResults;
+
+ try {
+ // Update deployment target in all build configurations
+ const buildConfigs = xcodeProject.hash.project.objects.XCBuildConfiguration || {};
+
+ for (const [uuid, buildConfig] of Object.entries(buildConfigs)) {
+ if (buildConfig && buildConfig.buildSettings) {
+ const currentTarget = buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET;
+ if (currentTarget && parseFloat(currentTarget) < parseFloat(CLERK_MIN_IOS_VERSION)) {
+ buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET = CLERK_MIN_IOS_VERSION;
+ }
+ }
+ }
+
+ console.log(`✅ Updated iOS deployment target to ${CLERK_MIN_IOS_VERSION}`);
+ } catch (error) {
+ console.error('❌ Error updating deployment target:', error.message);
+ }
+
+ return config;
+ });
+
+ config = withInfoPlist(config, modConfig => {
+ modConfig.modResults.ClerkExpoVersion = hostSdkVersion;
+ return modConfig;
+ });
+
+ return config;
+};
+
+/**
+ * Add packaging exclusions to Android app build.gradle to resolve
+ * duplicate META-INF file conflicts from clerk-android dependencies.
+ */
+const withClerkAndroid = (config, hostSdkVersion) => {
+ console.log('✅ Clerk Android plugin loaded');
+
+ config = withGradleProperties(config, modConfig => {
+ modConfig.modResults = modConfig.modResults.filter(
+ item => !(item.type === 'property' && item.key === HOST_SDK_VERSION_GRADLE_PROPERTY),
+ );
+ modConfig.modResults.push({ type: 'property', key: HOST_SDK_VERSION_GRADLE_PROPERTY, value: hostSdkVersion });
+ return modConfig;
+ });
+
+ return withAppBuildGradle(config, modConfig => {
+ let buildGradle = modConfig.modResults.contents;
+
+ // --- META-INF exclusion ---
+ if (!buildGradle.includes('META-INF/versions/9/OSGI-INF/MANIFEST.MF')) {
+ // AGP 8+ uses `packaging` DSL, older versions use `packagingOptions`
+ const packagingMatch = buildGradle.match(/packaging\s*\{/) || buildGradle.match(/packagingOptions\s*\{/);
+ if (packagingMatch) {
+ const blockName = packagingMatch[0].trim().replace(/\s*\{$/, '');
+ const resourcesExclude = `${blockName} {
+ // Clerk Android SDK: exclude duplicate META-INF files
+ resources {
+ excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
+ }`;
+
+ buildGradle = buildGradle.replace(new RegExp(`${blockName}\\s*\\{`), resourcesExclude);
+ } else {
+ // No packaging block found; append one at the end of the android block
+ const androidBlockEnd = buildGradle.lastIndexOf('}');
+ if (androidBlockEnd !== -1) {
+ const packagingBlock = `\n packaging {\n resources {\n excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']\n }\n }\n`;
+ buildGradle = buildGradle.slice(0, androidBlockEnd) + packagingBlock + buildGradle.slice(androidBlockEnd);
+ }
+ }
+ console.log('✅ Clerk Android packaging exclusions added');
+ }
+
+ // --- Kotlin metadata version check skip ---
+ if (!buildGradle.includes('-Xskip-metadata-version-check')) {
+ const kotlinOptionsMatch = buildGradle.match(/kotlinOptions\s*\{/);
+ if (kotlinOptionsMatch) {
+ buildGradle = buildGradle.replace(
+ /kotlinOptions\s*\{/,
+ `kotlinOptions {\n // Clerk: allow reading metadata from newer Kotlin versions\n freeCompilerArgs += ['-Xskip-metadata-version-check']`,
+ );
+ } else {
+ const androidMatch = buildGradle.match(/android\s*\{/);
+ if (androidMatch) {
+ buildGradle = buildGradle.replace(
+ /android\s*\{/,
+ `android {\n kotlinOptions {\n // Clerk: allow reading metadata from newer Kotlin versions\n freeCompilerArgs += ['-Xskip-metadata-version-check']\n }`,
+ );
+ }
+ }
+ console.log('✅ Clerk Android Kotlin metadata version check skip added');
+ }
+
+ modConfig.modResults.contents = buildGradle;
+ return modConfig;
+ });
+};
+
+/**
+ * Write ClerkKeychainService to Info.plist when keychainService is provided.
+ * This allows extension apps (watch, widget, app clip) to share the same
+ * keychain entry as the main app by using a custom service identifier.
+ */
+const withClerkKeychainService = (config, { keychainService } = {}) => {
+ if (!keychainService) {
+ return config;
+ }
+
+ return withInfoPlist(config, modConfig => {
+ modConfig.modResults.ClerkKeychainService = keychainService;
+ console.log(`✅ Set ClerkKeychainService in Info.plist: ${keychainService}`);
+ return modConfig;
+ });
+};
+
+/**
+ * Apply a custom theme to Clerk native components (iOS + Android).
+ *
+ * Accepts a `theme` prop pointing to a JSON file with optional keys:
+ * - colors: { primary, background, input, danger, success, warning,
+ * foreground, mutedForeground, primaryForeground, inputForeground,
+ * neutral, border, ring, muted, shadow, secondaryButtonBackground,
+ * secondaryButtonForeground } (hex color strings)
+ * - darkColors: same keys as colors (for dark mode)
+ * - design: { fontFamily: string, borderRadius: number }
+ *
+ * iOS: Embeds the parsed JSON into Info.plist under key "ClerkTheme".
+ * Android: Copies the JSON file to android/app/src/main/assets/clerk_theme.json.
+ */
+const VALID_COLOR_KEYS = [
+ 'primary',
+ 'background',
+ 'input',
+ 'danger',
+ 'success',
+ 'warning',
+ 'foreground',
+ 'mutedForeground',
+ 'primaryForeground',
+ 'inputForeground',
+ 'neutral',
+ 'border',
+ 'ring',
+ 'muted',
+ 'shadow',
+ 'secondaryButtonBackground',
+ 'secondaryButtonForeground',
+];
+
+const HEX_COLOR_REGEX = /^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
+
+function isPlainObject(value) {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function validateThemeJson(theme) {
+ if (!isPlainObject(theme)) {
+ throw new Error('Clerk theme: theme JSON must be a plain object');
+ }
+
+ const validateColors = (colors, label) => {
+ if (!isPlainObject(colors)) {
+ throw new Error(`Clerk theme: ${label} must be an object`);
+ }
+ for (const [key, value] of Object.entries(colors)) {
+ if (!VALID_COLOR_KEYS.includes(key)) {
+ console.warn(`⚠️ Clerk theme: unknown color key "${key}" in ${label}, ignoring`);
+ continue;
+ }
+ if (typeof value !== 'string' || !HEX_COLOR_REGEX.test(value)) {
+ throw new Error(`Clerk theme: invalid hex color for ${label}.${key}: "${value}"`);
+ }
+ }
+ };
+
+ if (theme.colors != null) validateColors(theme.colors, 'colors');
+ if (theme.darkColors != null) validateColors(theme.darkColors, 'darkColors');
+
+ if (theme.design != null) {
+ if (!isPlainObject(theme.design)) {
+ throw new Error(`Clerk theme: design must be an object`);
+ }
+ if (theme.design.fontFamily != null && typeof theme.design.fontFamily !== 'string') {
+ throw new Error(`Clerk theme: design.fontFamily must be a string`);
+ }
+ if (theme.design.borderRadius != null && typeof theme.design.borderRadius !== 'number') {
+ throw new Error(`Clerk theme: design.borderRadius must be a number`);
+ }
+ }
+}
+
+const withClerkTheme = (config, props = {}) => {
+ const { theme } = props;
+ if (!theme) return config;
+
+ // Resolve the theme file path relative to the project root
+ const themePath = path.resolve(theme);
+ if (!fs.existsSync(themePath)) {
+ console.warn(`⚠️ Clerk theme file not found: ${themePath}, skipping theme`);
+ return config;
+ }
+
+ let themeJson;
+ try {
+ themeJson = JSON.parse(fs.readFileSync(themePath, 'utf8'));
+ validateThemeJson(themeJson);
+ } catch (e) {
+ throw new Error(`Clerk theme: failed to parse ${themePath}: ${e.message}`);
+ }
+
+ // iOS: Embed theme in Info.plist under "ClerkTheme"
+ config = withInfoPlist(config, modConfig => {
+ modConfig.modResults.ClerkTheme = themeJson;
+ console.log('✅ Embedded Clerk theme in Info.plist');
+ return modConfig;
+ });
+
+ // Android: Copy theme JSON to assets
+ config = withDangerousMod(config, [
+ 'android',
+ async config => {
+ const assetsDir = path.join(config.modRequest.platformProjectRoot, 'app', 'src', 'main', 'assets');
+ if (!fs.existsSync(assetsDir)) {
+ fs.mkdirSync(assetsDir, { recursive: true });
+ }
+ const destPath = path.join(assetsDir, 'clerk_theme.json');
+ fs.writeFileSync(destPath, JSON.stringify(themeJson, null, 2) + '\n');
+ console.log('✅ Copied Clerk theme to Android assets');
+ return config;
+ },
+ ]);
+
+ return config;
+};
+
+const withClerkExpoNativeComponents = (config, props = {}, resolve = resolveClerkExpo) => {
+ const clerkExpo = resolve(config._internal?.projectRoot);
+ if (clerkExpo && fs.existsSync(path.join(clerkExpo.dir, 'expo-module.config.json'))) {
+ throw new Error(
+ `Clerk: @clerk/expo@${clerkExpo.version} still bundles the Clerk native module, which conflicts with @clerk/expo-native-components. Upgrade @clerk/expo to a version that supports @clerk/expo-native-components.`,
+ );
+ }
+ // Native requests report the @clerk/expo version in the x-clerk-host-sdk-version header.
+ const hostSdkVersion = clerkExpo?.version ?? packageJson.version;
+
+ config = withClerkIOS(config, hostSdkVersion);
+ config = withClerkAndroid(config, hostSdkVersion);
+ config = withClerkKeychainService(config, props);
+ config = withClerkTheme(config, props);
+ return config;
+};
+
+module.exports = createRunOncePlugin(withClerkExpoNativeComponents, packageJson.name, packageJson.version);
+module.exports._testing = {
+ withClerkExpoNativeComponents,
+ validateThemeJson,
+ isPlainObject,
+ VALID_COLOR_KEYS,
+ HEX_COLOR_REGEX,
+};
diff --git a/packages/expo/expo-module.config.json b/packages/expo-native-components/expo-module.config.json
similarity index 100%
rename from packages/expo/expo-module.config.json
rename to packages/expo-native-components/expo-module.config.json
diff --git a/packages/expo/ios/ClerkAppDelegateSubscriber.swift b/packages/expo-native-components/ios/ClerkAppDelegateSubscriber.swift
similarity index 100%
rename from packages/expo/ios/ClerkAppDelegateSubscriber.swift
rename to packages/expo-native-components/ios/ClerkAppDelegateSubscriber.swift
diff --git a/packages/expo/ios/ClerkAuthNativeView.swift b/packages/expo-native-components/ios/ClerkAuthNativeView.swift
similarity index 100%
rename from packages/expo/ios/ClerkAuthNativeView.swift
rename to packages/expo-native-components/ios/ClerkAuthNativeView.swift
diff --git a/packages/expo/ios/ClerkExpo.podspec b/packages/expo-native-components/ios/ClerkExpo.podspec
similarity index 100%
rename from packages/expo/ios/ClerkExpo.podspec
rename to packages/expo-native-components/ios/ClerkExpo.podspec
diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo-native-components/ios/ClerkExpoModule.swift
similarity index 100%
rename from packages/expo/ios/ClerkExpoModule.swift
rename to packages/expo-native-components/ios/ClerkExpoModule.swift
diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo-native-components/ios/ClerkNativeBridge.swift
similarity index 100%
rename from packages/expo/ios/ClerkNativeBridge.swift
rename to packages/expo-native-components/ios/ClerkNativeBridge.swift
diff --git a/packages/expo/ios/ClerkNativeViewHost.swift b/packages/expo-native-components/ios/ClerkNativeViewHost.swift
similarity index 100%
rename from packages/expo/ios/ClerkNativeViewHost.swift
rename to packages/expo-native-components/ios/ClerkNativeViewHost.swift
diff --git a/packages/expo/ios/ClerkUserButtonNativeView.swift b/packages/expo-native-components/ios/ClerkUserButtonNativeView.swift
similarity index 100%
rename from packages/expo/ios/ClerkUserButtonNativeView.swift
rename to packages/expo-native-components/ios/ClerkUserButtonNativeView.swift
diff --git a/packages/expo/ios/ClerkUserProfileNativeView.swift b/packages/expo-native-components/ios/ClerkUserProfileNativeView.swift
similarity index 100%
rename from packages/expo/ios/ClerkUserProfileNativeView.swift
rename to packages/expo-native-components/ios/ClerkUserProfileNativeView.swift
diff --git a/packages/expo/ios/Tests/ClerkAuthNativeViewPaperTests.swift b/packages/expo-native-components/ios/Tests/ClerkAuthNativeViewPaperTests.swift
similarity index 100%
rename from packages/expo/ios/Tests/ClerkAuthNativeViewPaperTests.swift
rename to packages/expo-native-components/ios/Tests/ClerkAuthNativeViewPaperTests.swift
diff --git a/packages/expo/ios/Tests/ClerkNativeBridgeTests.swift b/packages/expo-native-components/ios/Tests/ClerkNativeBridgeTests.swift
similarity index 100%
rename from packages/expo/ios/Tests/ClerkNativeBridgeTests.swift
rename to packages/expo-native-components/ios/Tests/ClerkNativeBridgeTests.swift
diff --git a/packages/expo/ios/Tests/ClerkNativeViewHostTests.swift b/packages/expo-native-components/ios/Tests/ClerkNativeViewHostTests.swift
similarity index 100%
rename from packages/expo/ios/Tests/ClerkNativeViewHostTests.swift
rename to packages/expo-native-components/ios/Tests/ClerkNativeViewHostTests.swift
diff --git a/packages/expo/ios/Tests/ClerkUserProfileCustomPageStateTests.swift b/packages/expo-native-components/ios/Tests/ClerkUserProfileCustomPageStateTests.swift
similarity index 100%
rename from packages/expo/ios/Tests/ClerkUserProfileCustomPageStateTests.swift
rename to packages/expo-native-components/ios/Tests/ClerkUserProfileCustomPageStateTests.swift
diff --git a/packages/expo-native-components/package.json b/packages/expo-native-components/package.json
new file mode 100644
index 00000000000..0d6f430f012
--- /dev/null
+++ b/packages/expo-native-components/package.json
@@ -0,0 +1,82 @@
+{
+ "name": "@clerk/expo-native-components",
+ "version": "0.0.1",
+ "description": "Native Clerk components and native SDK integration for Expo, powered by clerk-ios and clerk-android",
+ "keywords": [
+ "react",
+ "react-native",
+ "expo",
+ "clerk",
+ "auth",
+ "authentication",
+ "native"
+ ],
+ "homepage": "https://clerk.com/",
+ "bugs": {
+ "url": "https://github.com/clerk/javascript/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/clerk/javascript.git",
+ "directory": "packages/expo-native-components"
+ },
+ "license": "MIT",
+ "author": "Clerk",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "./app.plugin.js": "./app.plugin.js",
+ "./package.json": "./package.json"
+ },
+ "main": "./dist/index.js",
+ "source": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "files": [
+ "dist",
+ "android",
+ "ios",
+ "src/specs",
+ "expo-module.config.json",
+ "react-native.config.js",
+ "app.plugin.js",
+ "app.plugin.d.ts"
+ ],
+ "scripts": {
+ "build": "tsdown",
+ "build:declarations": "tsc -p tsconfig.declarations.json",
+ "clean": "rimraf ./dist",
+ "dev": "tsdown --watch",
+ "dev:pub": "pnpm dev -- --env.publish",
+ "format": "node ../../scripts/format-package.mjs",
+ "format:check": "node ../../scripts/format-package.mjs --check",
+ "lint": "eslint src",
+ "test": "vitest run",
+ "test:watch": "vitest watch"
+ },
+ "dependencies": {
+ "@clerk/react": "workspace:^",
+ "tslib": "catalog:repo"
+ },
+ "devDependencies": {
+ "@expo/config-plugins": "^54.0.4",
+ "react-native": "^0.86.0"
+ },
+ "peerDependencies": {
+ "expo": "catalog:peer-expo",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-native": ">=0.75"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "codegenConfig": {
+ "name": "ClerkExpoSpec",
+ "type": "all",
+ "jsSrcsDir": "src/specs"
+ }
+}
diff --git a/packages/expo/react-native.config.js b/packages/expo-native-components/react-native.config.js
similarity index 100%
rename from packages/expo/react-native.config.js
rename to packages/expo-native-components/react-native.config.js
diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo-native-components/src/AuthView.tsx
similarity index 86%
rename from packages/expo/src/native/AuthView.tsx
rename to packages/expo-native-components/src/AuthView.tsx
index 3f4b51ee346..99e1d395348 100644
--- a/packages/expo/src/native/AuthView.tsx
+++ b/packages/expo-native-components/src/AuthView.tsx
@@ -2,9 +2,9 @@ import { useCallback } from 'react';
import type { NativeSyntheticEvent } from 'react-native';
import { Text, View } from 'react-native';
-import NativeClerkAuthView from '../specs/NativeClerkAuthView';
-import { isNativeSupported } from '../utils/native-module';
import type { AuthViewProps } from './AuthView.types';
+import NativeClerkAuthView from './specs/NativeClerkAuthView';
+import { isNativeSupported } from './utils/native-module';
type AuthNativeEvent = NativeSyntheticEvent>;
@@ -24,7 +24,7 @@ type AuthNativeEvent = NativeSyntheticEvent>;
*
* @example
* ```tsx
- * import { AuthView } from '@clerk/expo/native';
+ * import { AuthView } from '@clerk/expo-native-components';
* import { useAuth } from '@clerk/expo';
*
* export default function SignInScreen() {
@@ -63,7 +63,7 @@ export function AuthView({
{!isNativeSupported
? 'Native AuthView is only available on iOS and Android'
- : 'Native AuthView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'}
+ : 'Native AuthView requires a development build with the @clerk/expo-native-components config plugin. Add "@clerk/expo-native-components" to your app.json plugins array and rebuild your native app.'}
);
diff --git a/packages/expo/src/native/AuthView.types.ts b/packages/expo-native-components/src/AuthView.types.ts
similarity index 100%
rename from packages/expo/src/native/AuthView.types.ts
rename to packages/expo-native-components/src/AuthView.types.ts
diff --git a/packages/expo/src/native/EmbeddedNavigation.types.ts b/packages/expo-native-components/src/EmbeddedNavigation.types.ts
similarity index 100%
rename from packages/expo/src/native/EmbeddedNavigation.types.ts
rename to packages/expo-native-components/src/EmbeddedNavigation.types.ts
diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo-native-components/src/UserButton.tsx
similarity index 94%
rename from packages/expo/src/native/UserButton.tsx
rename to packages/expo-native-components/src/UserButton.tsx
index a1406f0652e..fc2504781b2 100644
--- a/packages/expo/src/native/UserButton.tsx
+++ b/packages/expo-native-components/src/UserButton.tsx
@@ -3,8 +3,7 @@ import { useMemo, useRef } from 'react';
import type { NativeSyntheticEvent } from 'react-native';
import { StyleSheet, useWindowDimensions } from 'react-native';
-import NativeClerkUserButtonView from '../specs/NativeClerkUserButtonView';
-import { isNativeSupported } from '../utils/native-module';
+import NativeClerkUserButtonView from './specs/NativeClerkUserButtonView';
import type {
NativeUserProfileNavigationHandle,
UserProfileCustomDestination,
@@ -16,6 +15,7 @@ import {
UserProfileCustomPageHosts,
useUserProfileCustomPages,
} from './UserProfileCustomPages';
+import { isNativeSupported } from './utils/native-module';
type CustomizableNativeUserButtonProps = ComponentProps> & {
customPages?: string;
@@ -47,7 +47,7 @@ export interface UserButtonProps {
*
* @example
* ```tsx
- * import { UserButton } from '@clerk/expo/native';
+ * import { UserButton } from '@clerk/expo-native-components';
*
* export default function Home() {
* return (
diff --git a/packages/expo/src/native/UserProfileCustomPages.tsx b/packages/expo-native-components/src/UserProfileCustomPages.tsx
similarity index 100%
rename from packages/expo/src/native/UserProfileCustomPages.tsx
rename to packages/expo-native-components/src/UserProfileCustomPages.tsx
diff --git a/packages/expo/src/native/UserProfileView.tsx b/packages/expo-native-components/src/UserProfileView.tsx
similarity index 92%
rename from packages/expo/src/native/UserProfileView.tsx
rename to packages/expo-native-components/src/UserProfileView.tsx
index 3c39d955375..eb3e59bee96 100644
--- a/packages/expo/src/native/UserProfileView.tsx
+++ b/packages/expo-native-components/src/UserProfileView.tsx
@@ -3,9 +3,8 @@ import { useCallback, useMemo, useRef } from 'react';
import type { NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native';
import { StyleSheet, Text, View } from 'react-native';
-import NativeClerkUserProfileView from '../specs/NativeClerkUserProfileView';
-import { isNativeSupported } from '../utils/native-module';
import type { EmbeddedNavigationProps } from './EmbeddedNavigation.types';
+import NativeClerkUserProfileView from './specs/NativeClerkUserProfileView';
import type {
NativeUserProfileNavigationHandle,
UserProfileCustomDestination,
@@ -17,6 +16,7 @@ import {
UserProfileCustomPageHosts,
useUserProfileCustomPages,
} from './UserProfileCustomPages';
+import { isNativeSupported } from './utils/native-module';
type CustomizableNativeUserProfileProps = ComponentProps> & {
customPages?: string;
@@ -75,7 +75,7 @@ export interface UserProfileViewProps extends EmbeddedNavigationProps {
*
* @example
* ```tsx
- * import { UserProfileView } from '@clerk/expo/native';
+ * import { UserProfileView } from '@clerk/expo-native-components';
* import { useAuth } from '@clerk/expo';
*
* export default function ProfileScreen() {
@@ -128,7 +128,7 @@ export function UserProfileView({
{!isNativeSupported
? 'Native UserProfileView is only available on iOS and Android'
- : 'Native UserProfileView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'}
+ : 'Native UserProfileView requires a development build with the @clerk/expo-native-components config plugin. Add "@clerk/expo-native-components" to your app.json plugins array and rebuild your native app.'}
);
diff --git a/packages/expo/src/native/__tests__/AuthView.test.tsx b/packages/expo-native-components/src/__tests__/AuthView.test.tsx
similarity index 94%
rename from packages/expo/src/native/__tests__/AuthView.test.tsx
rename to packages/expo-native-components/src/__tests__/AuthView.test.tsx
index 6b3029ecdda..528e251fb2e 100644
--- a/packages/expo/src/native/__tests__/AuthView.test.tsx
+++ b/packages/expo-native-components/src/__tests__/AuthView.test.tsx
@@ -10,13 +10,13 @@ const mocks = vi.hoisted(() => {
};
});
-vi.mock('../../specs/NativeClerkAuthView', () => {
+vi.mock('../specs/NativeClerkAuthView', () => {
return {
default: mocks.NativeClerkAuthView,
};
});
-vi.mock('../../utils/native-module', () => {
+vi.mock('../utils/native-module', () => {
return {
isNativeSupported: true,
};
diff --git a/packages/expo/src/native/__tests__/UserButton.test.tsx b/packages/expo-native-components/src/__tests__/UserButton.test.tsx
similarity index 94%
rename from packages/expo/src/native/__tests__/UserButton.test.tsx
rename to packages/expo-native-components/src/__tests__/UserButton.test.tsx
index d4eaef80382..d414aa0f89f 100644
--- a/packages/expo/src/native/__tests__/UserButton.test.tsx
+++ b/packages/expo-native-components/src/__tests__/UserButton.test.tsx
@@ -8,14 +8,14 @@ const mocks = vi.hoisted(() => ({
nativeProps: vi.fn(),
}));
-vi.mock('../../specs/NativeClerkUserButtonView', () => ({
+vi.mock('../specs/NativeClerkUserButtonView', () => ({
default: React.forwardRef((props: Record, _ref) => {
mocks.nativeProps(props);
return null;
}),
}));
-vi.mock('../../utils/native-module', () => ({
+vi.mock('../utils/native-module', () => ({
isNativeSupported: true,
}));
diff --git a/packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx b/packages/expo-native-components/src/__tests__/UserProfileCustomPages.test.tsx
similarity index 100%
rename from packages/expo/src/native/__tests__/UserProfileCustomPages.test.tsx
rename to packages/expo-native-components/src/__tests__/UserProfileCustomPages.test.tsx
diff --git a/packages/expo/src/native/__tests__/UserProfileView.test.tsx b/packages/expo-native-components/src/__tests__/UserProfileView.test.tsx
similarity index 98%
rename from packages/expo/src/native/__tests__/UserProfileView.test.tsx
rename to packages/expo-native-components/src/__tests__/UserProfileView.test.tsx
index 34af7275e6d..619c417305e 100644
--- a/packages/expo/src/native/__tests__/UserProfileView.test.tsx
+++ b/packages/expo-native-components/src/__tests__/UserProfileView.test.tsx
@@ -14,7 +14,7 @@ const mocks = vi.hoisted(() => {
};
});
-vi.mock('../../specs/NativeClerkUserProfileView', () => {
+vi.mock('../specs/NativeClerkUserProfileView', () => {
return {
default: React.forwardRef((props: { children?: React.ReactNode }, ref) => {
React.useImperativeHandle(ref, () => ({ navigateCustomPage: mocks.navigateCustomPage }));
@@ -24,7 +24,7 @@ vi.mock('../../specs/NativeClerkUserProfileView', () => {
};
});
-vi.mock('../../utils/native-module', () => {
+vi.mock('../utils/native-module', () => {
return {
isNativeSupported: true,
};
diff --git a/packages/expo-native-components/src/__tests__/appPlugin.test.js b/packages/expo-native-components/src/__tests__/appPlugin.test.js
new file mode 100644
index 00000000000..bbb2d247250
--- /dev/null
+++ b/packages/expo-native-components/src/__tests__/appPlugin.test.js
@@ -0,0 +1,53 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export
+const { withClerkExpoNativeComponents } = require('../../app.plugin.js')._testing;
+
+const applyMod = (config, platform, mod, modResults) =>
+ config.mods[platform][mod]({ ...config, modRequest: {}, modResults });
+
+describe('withClerkExpoNativeComponents', () => {
+ let clerkExpoDir;
+
+ beforeEach(() => {
+ vi.spyOn(console, 'log').mockImplementation(() => {});
+ clerkExpoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-expo-'));
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ fs.rmSync(clerkExpoDir, { recursive: true, force: true });
+ });
+
+ test('reports the installed @clerk/expo version on iOS and Android', async () => {
+ const config = withClerkExpoNativeComponents({ name: 'test', slug: 'test' }, {}, () => ({
+ dir: clerkExpoDir,
+ version: '4.8.0',
+ }));
+
+ const infoPlist = await applyMod(config, 'ios', 'infoPlist', {});
+ const gradleProperties = await applyMod(config, 'android', 'gradleProperties', [
+ { type: 'property', key: 'clerkExpo.hostSdkVersion', value: '4.7.0' },
+ ]);
+
+ expect(infoPlist.modResults.ClerkExpoVersion).toBe('4.8.0');
+ expect(gradleProperties.modResults).toEqual([
+ { type: 'property', key: 'clerkExpo.hostSdkVersion', value: '4.8.0' },
+ ]);
+ });
+
+ test('throws when the installed @clerk/expo still bundles the native module', () => {
+ fs.writeFileSync(path.join(clerkExpoDir, 'expo-module.config.json'), '{}');
+
+ expect(() =>
+ withClerkExpoNativeComponents({ name: 'test', slug: 'test' }, {}, () => ({
+ dir: clerkExpoDir,
+ version: '4.7.1',
+ })),
+ ).toThrow('@clerk/expo@4.7.1 still bundles the Clerk native module');
+ });
+});
diff --git a/packages/expo/src/__tests__/appPlugin.theme.test.js b/packages/expo-native-components/src/__tests__/appPlugin.theme.test.js
similarity index 71%
rename from packages/expo/src/__tests__/appPlugin.theme.test.js
rename to packages/expo-native-components/src/__tests__/appPlugin.theme.test.js
index fcff7577b0e..c0f89d02ab8 100644
--- a/packages/expo/src/__tests__/appPlugin.theme.test.js
+++ b/packages/expo-native-components/src/__tests__/appPlugin.theme.test.js
@@ -2,54 +2,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export
const clerkPlugin = require('../../app.plugin.js');
-const { withClerkFaceIDPermission, validateThemeJson } = clerkPlugin._testing;
-
-function applyInfoPlistMod(config, modResults) {
- return config.mods.ios.infoPlist({
- ...config,
- modRequest: {},
- modResults,
- });
-}
-
-describe('withClerkFaceIDPermission', () => {
- test('adds the configured Face ID usage description', async () => {
- const config = withClerkFaceIDPermission(
- { name: 'test', slug: 'test' },
- { faceIDPermission: 'Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.' },
- );
-
- const result = await applyInfoPlistMod(config, {});
-
- expect(result.modResults.NSFaceIDUsageDescription).toBe('Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.');
- });
-
- test('preserves an app-provided Face ID usage description', async () => {
- const config = withClerkFaceIDPermission(
- { name: 'test', slug: 'test' },
- { faceIDPermission: 'Clerk-provided description' },
- );
-
- const result = await applyInfoPlistMod(config, {
- NSFaceIDUsageDescription: 'App-provided description',
- });
-
- expect(result.modResults.NSFaceIDUsageDescription).toBe('App-provided description');
- });
-
- test('does not configure the Info.plist without an explicit permission description', () => {
- const config = { name: 'test', slug: 'test' };
-
- expect(withClerkFaceIDPermission(config)).toBe(config);
- expect(config).not.toHaveProperty('mods');
- });
-
- test.each([null, '', ' ', true])('rejects an invalid permission description: %j', faceIDPermission => {
- expect(() => withClerkFaceIDPermission({ name: 'test', slug: 'test' }, { faceIDPermission })).toThrow(
- 'faceIDPermission must be a non-empty string',
- );
- });
-});
+const { validateThemeJson } = clerkPlugin._testing;
describe('validateThemeJson', () => {
beforeEach(() => {
diff --git a/packages/expo/src/native/__tests__/useAuthViewState.test.tsx b/packages/expo-native-components/src/__tests__/useAuthViewState.test.tsx
similarity index 97%
rename from packages/expo/src/native/__tests__/useAuthViewState.test.tsx
rename to packages/expo-native-components/src/__tests__/useAuthViewState.test.tsx
index 988f2a57729..7b16d1816ef 100644
--- a/packages/expo/src/native/__tests__/useAuthViewState.test.tsx
+++ b/packages/expo-native-components/src/__tests__/useAuthViewState.test.tsx
@@ -1,7 +1,7 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
-import type { NativeAuthFlowState } from '../../specs/NativeClerkModule.types';
+import type { NativeAuthFlowState } from '../specs/NativeClerkModule.types';
import { useAuthViewState } from '../useAuthViewState';
const mocks = vi.hoisted(() => ({
@@ -13,11 +13,11 @@ const mocks = vi.hoisted(() => ({
remove: vi.fn(),
}));
-vi.mock('../../hooks/useAuth', () => ({
+vi.mock('@clerk/react', () => ({
useAuth: () => mocks.auth,
}));
-vi.mock('../../utils/native-module', () => ({
+vi.mock('../utils/native-module', () => ({
get ClerkExpoModule() {
return mocks.module;
},
diff --git a/packages/expo-native-components/src/index.ts b/packages/expo-native-components/src/index.ts
new file mode 100644
index 00000000000..c102a43da33
--- /dev/null
+++ b/packages/expo-native-components/src/index.ts
@@ -0,0 +1,49 @@
+/**
+ * Native UI components for Clerk authentication in Expo apps.
+ *
+ * These components provide pre-built, native authentication experiences powered by:
+ * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios
+ * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android
+ *
+ * ## Installation
+ *
+ * Native components require the `@clerk/expo-native-components` config plugin in your `app.json`, alongside `@clerk/expo`:
+ *
+ * ```json
+ * {
+ * "expo": {
+ * "plugins": ["@clerk/expo", "@clerk/expo-native-components"]
+ * }
+ * }
+ * ```
+ *
+ * Then run `npx expo prebuild` to generate native code.
+ *
+ * ## Components
+ *
+ * - {@link AuthView} - Authentication flow (sign-in/sign-up), renders inline
+ * - {@link UserProfileView} - User profile and account management, renders inline
+ * - {@link UserButton} - Avatar button that opens the native user profile
+ *
+ * @module @clerk/expo-native-components
+ */
+
+export { AuthView } from './AuthView';
+export type { AuthViewProps, AuthViewMode } from './AuthView.types';
+export type { EmbeddedNavigationProps } from './EmbeddedNavigation.types';
+export { useAuthViewState } from './useAuthViewState';
+export type { UseAuthViewStateReturn } from './useAuthViewState';
+export { UserButton } from './UserButton';
+export type { UserButtonProps, UserButtonUserProfileProps } from './UserButton';
+export { useUserProfileCustomPageNavigation } from './UserProfileCustomPages';
+export type {
+ UserProfileCustomPageNavigation,
+ UserProfileCustomDestination,
+ UserProfileCustomPage,
+ UserProfileCustomPageIcon,
+ UserProfileCustomPagePlacement,
+ UserProfileRow,
+ UserProfileSection,
+} from './UserProfileCustomPages';
+export { UserProfileView } from './UserProfileView';
+export type { UserProfileViewProps } from './UserProfileView';
diff --git a/packages/expo/src/specs/NativeClerkAuthView.android.ts b/packages/expo-native-components/src/specs/NativeClerkAuthView.android.ts
similarity index 100%
rename from packages/expo/src/specs/NativeClerkAuthView.android.ts
rename to packages/expo-native-components/src/specs/NativeClerkAuthView.android.ts
diff --git a/packages/expo/src/specs/NativeClerkAuthView.ts b/packages/expo-native-components/src/specs/NativeClerkAuthView.ts
similarity index 100%
rename from packages/expo/src/specs/NativeClerkAuthView.ts
rename to packages/expo-native-components/src/specs/NativeClerkAuthView.ts
diff --git a/packages/expo-native-components/src/specs/NativeClerkModule.ts b/packages/expo-native-components/src/specs/NativeClerkModule.ts
new file mode 100644
index 00000000000..072d011f539
--- /dev/null
+++ b/packages/expo-native-components/src/specs/NativeClerkModule.ts
@@ -0,0 +1,6 @@
+import { requireOptionalNativeModule } from 'expo';
+
+import type { Spec } from './NativeClerkModule.types';
+
+// Optional so it resolves to null in Expo Go instead of throwing at import time.
+export default requireOptionalNativeModule('ClerkExpo');
diff --git a/packages/expo-native-components/src/specs/NativeClerkModule.types.ts b/packages/expo-native-components/src/specs/NativeClerkModule.types.ts
new file mode 100644
index 00000000000..9df94448a91
--- /dev/null
+++ b/packages/expo-native-components/src/specs/NativeClerkModule.types.ts
@@ -0,0 +1,9 @@
+export type NativeAuthFlowState = {
+ isLoaded: boolean;
+ isAuthFlowComplete: boolean;
+};
+
+export interface Spec {
+ addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void };
+ getAuthFlowState?(): Promise;
+}
diff --git a/packages/expo-native-components/src/specs/NativeClerkModule.web.ts b/packages/expo-native-components/src/specs/NativeClerkModule.web.ts
new file mode 100644
index 00000000000..625a2ed22ae
--- /dev/null
+++ b/packages/expo-native-components/src/specs/NativeClerkModule.web.ts
@@ -0,0 +1,3 @@
+import type { Spec } from './NativeClerkModule.types';
+
+export default null as Spec | null;
diff --git a/packages/expo/src/specs/NativeClerkUserButtonView.android.ts b/packages/expo-native-components/src/specs/NativeClerkUserButtonView.android.ts
similarity index 100%
rename from packages/expo/src/specs/NativeClerkUserButtonView.android.ts
rename to packages/expo-native-components/src/specs/NativeClerkUserButtonView.android.ts
diff --git a/packages/expo/src/specs/NativeClerkUserButtonView.ts b/packages/expo-native-components/src/specs/NativeClerkUserButtonView.ts
similarity index 100%
rename from packages/expo/src/specs/NativeClerkUserButtonView.ts
rename to packages/expo-native-components/src/specs/NativeClerkUserButtonView.ts
diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.android.ts b/packages/expo-native-components/src/specs/NativeClerkUserProfileView.android.ts
similarity index 100%
rename from packages/expo/src/specs/NativeClerkUserProfileView.android.ts
rename to packages/expo-native-components/src/specs/NativeClerkUserProfileView.android.ts
diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.ts b/packages/expo-native-components/src/specs/NativeClerkUserProfileView.ts
similarity index 100%
rename from packages/expo/src/specs/NativeClerkUserProfileView.ts
rename to packages/expo-native-components/src/specs/NativeClerkUserProfileView.ts
diff --git a/packages/expo/src/specs/__tests__/native-view.web.test.ts b/packages/expo-native-components/src/specs/__tests__/native-view.web.test.ts
similarity index 100%
rename from packages/expo/src/specs/__tests__/native-view.web.test.ts
rename to packages/expo-native-components/src/specs/__tests__/native-view.web.test.ts
diff --git a/packages/expo/src/native/useAuthViewState.ts b/packages/expo-native-components/src/useAuthViewState.ts
similarity index 94%
rename from packages/expo/src/native/useAuthViewState.ts
rename to packages/expo-native-components/src/useAuthViewState.ts
index ecb793f84f1..c32f8e3e637 100644
--- a/packages/expo/src/native/useAuthViewState.ts
+++ b/packages/expo-native-components/src/useAuthViewState.ts
@@ -1,8 +1,8 @@
+import { useAuth } from '@clerk/react';
import { useEffect, useState } from 'react';
-import { useAuth } from '../hooks/useAuth';
-import type { NativeAuthFlowState } from '../specs/NativeClerkModule.types';
-import { ClerkExpoModule as ClerkExpo } from '../utils/native-module';
+import type { NativeAuthFlowState } from './specs/NativeClerkModule.types';
+import { ClerkExpoModule as ClerkExpo } from './utils/native-module';
const nativeAuthFlowChangedEvent = 'clerkNativeAuthFlowChanged';
diff --git a/packages/expo-native-components/src/utils/native-module.ts b/packages/expo-native-components/src/utils/native-module.ts
new file mode 100644
index 00000000000..a416ccd8917
--- /dev/null
+++ b/packages/expo-native-components/src/utils/native-module.ts
@@ -0,0 +1,7 @@
+import { Platform } from 'react-native';
+
+import NativeClerkModule from '../specs/NativeClerkModule';
+
+export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android';
+
+export const ClerkExpoModule = isNativeSupported ? NativeClerkModule : null;
diff --git a/packages/expo-native-components/tsconfig.declarations.json b/packages/expo-native-components/tsconfig.declarations.json
new file mode 100644
index 00000000000..ac04a85ce27
--- /dev/null
+++ b/packages/expo-native-components/tsconfig.declarations.json
@@ -0,0 +1,16 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "./src",
+ "incremental": false,
+ "skipLibCheck": true,
+ "noEmit": false,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "declarationMap": true,
+ "sourceMap": false,
+ "declarationDir": "./dist"
+ },
+ "include": ["src"],
+ "exclude": ["**/__tests__/**/*", "app.plugin.js"]
+}
diff --git a/packages/expo-native-components/tsconfig.json b/packages/expo-native-components/tsconfig.json
new file mode 100644
index 00000000000..5510c6beb95
--- /dev/null
+++ b/packages/expo-native-components/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "outDir": "dist",
+ "lib": ["es2019", "dom"],
+ "jsx": "react-jsx",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "importHelpers": true,
+ "declaration": true,
+ "declarationMap": false,
+ "noImplicitReturns": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "resolveJsonModule": true,
+ "sourceMap": false,
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "allowJs": true,
+ "target": "ES2019",
+ "noEmitOnError": false,
+ "incremental": true,
+ "moduleSuffixes": [".web", ".ios", ".android", ".native", ""]
+ },
+ "include": ["src", "app.plugin.js"]
+}
diff --git a/packages/expo-native-components/tsconfig.test.json b/packages/expo-native-components/tsconfig.test.json
new file mode 100644
index 00000000000..5635d6cd1b7
--- /dev/null
+++ b/packages/expo-native-components/tsconfig.test.json
@@ -0,0 +1,6 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "sourceMap": true
+ }
+}
diff --git a/packages/expo-native-components/tsdown.config.mts b/packages/expo-native-components/tsdown.config.mts
new file mode 100644
index 00000000000..e84f7249174
--- /dev/null
+++ b/packages/expo-native-components/tsdown.config.mts
@@ -0,0 +1,33 @@
+import type { Options } from 'tsdown';
+import { defineConfig } from 'tsdown';
+
+import { runAfterLast } from '../../scripts/utils.ts';
+
+function preserveRelativeImports(id: string) {
+ return id.startsWith('.');
+}
+
+export default defineConfig(overrideOptions => {
+ const isWatch = !!overrideOptions.watch;
+ const shouldPublish = !!overrideOptions.env?.publish;
+
+ const options: Options = {
+ format: 'cjs',
+ fixedExtension: false,
+ outDir: './dist',
+ entry: ['./src/**/*.{ts,tsx,js,jsx}', '!./src/**/*.test.{ts,tsx,js}', '!./src/**/__tests__/**'],
+ unbundle: true,
+ clean: true,
+ minify: false,
+ sourcemap: true,
+ deps: {
+ // Keep relative import specifiers unchanged so Metro can apply platform-specific resolution.
+ neverBundle: preserveRelativeImports,
+ },
+ define: {
+ __DEV__: `${isWatch}`,
+ },
+ };
+
+ return runAfterLast(['pnpm build:declarations', shouldPublish && 'pkglab pub --ping'])(options);
+});
diff --git a/packages/expo-native-components/vitest.config.mts b/packages/expo-native-components/vitest.config.mts
new file mode 100644
index 00000000000..20bc22a33db
--- /dev/null
+++ b/packages/expo-native-components/vitest.config.mts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ plugins: [],
+ test: {
+ environment: 'jsdom',
+ includeSource: ['**/*.{js,ts,jsx,tsx}'],
+ setupFiles: './vitest.setup.mts',
+ },
+});
diff --git a/packages/expo-native-components/vitest.setup.mts b/packages/expo-native-components/vitest.setup.mts
new file mode 100644
index 00000000000..283c324f550
--- /dev/null
+++ b/packages/expo-native-components/vitest.setup.mts
@@ -0,0 +1,22 @@
+import { cleanup } from '@testing-library/react';
+import { afterEach, beforeAll, vi } from 'vitest';
+
+// Mock globalThis.expo for expo-modules-core
+if (!globalThis.expo) {
+ // @ts-expect-error - Mocking expo for tests
+ globalThis.expo = {
+ EventEmitter: vi.fn(),
+ };
+}
+
+// Define __DEV__ for expo-modules-core
+if (typeof globalThis.__DEV__ === 'undefined') {
+ // @ts-expect-error - Mocking __DEV__ for tests
+ globalThis.__DEV__ = false;
+}
+
+beforeAll(() => {});
+
+afterEach(() => {
+ cleanup();
+});
diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js
index f8483877ff3..ecba866b513 100644
--- a/packages/expo/app.plugin.js
+++ b/packages/expo/app.plugin.js
@@ -1,27 +1,15 @@
/**
- * Expo config plugin for @clerk/clerk-expo
- * Automatically configures iOS and Android to work with Clerk native components
+ * Expo config plugin for @clerk/expo
*
* When this plugin is used:
- * 1. iOS is configured with the required deployment target and metadata
- * 2. Android is configured with packaging exclusions for dependencies
- *
- * Native modules and views are registered via Expo Modules autolinking.
+ * 1. Android registers the hosted auth callback intent filter
+ * 2. iOS gets the Sign in with Apple entitlement and, when configured, the Face ID usage description
+ * 3. If @clerk/expo-native-components is installed, its config plugin is applied for the Clerk native SDKs
*/
-const {
- AndroidConfig,
- withXcodeProject,
- withDangerousMod,
- withInfoPlist,
- withAppBuildGradle,
- withAndroidManifest,
- withEntitlementsPlist,
-} = require('@expo/config-plugins');
-const path = require('path');
-const fs = require('fs');
-const packageJson = require('./package.json');
+const { AndroidConfig, withAndroidManifest, withEntitlementsPlist, withInfoPlist } = require('@expo/config-plugins');
-const CLERK_MIN_IOS_VERSION = '17.0';
+const CLERK_EXPO_NATIVE = '@clerk/expo-native-components';
+const CLERK_EXPO_NATIVE_OPTIONS = ['keychainService', 'theme'];
const addHostedAuthIntentFilter = (mainActivity, packageName) => {
const callbackHost = `${packageName}.hosted-callback`;
@@ -52,80 +40,8 @@ const addHostedAuthIntentFilter = (mainActivity, packageName) => {
mainActivity['intent-filter'] = intentFilters;
};
-const withClerkIOS = config => {
- console.log('✅ Clerk iOS plugin loaded');
-
- // IMPORTANT: Set iOS deployment target in Podfile.properties.json BEFORE pod install
- // This ensures ClerkExpo pod gets installed (it requires iOS 17.0)
- config = withDangerousMod(config, [
- 'ios',
- async config => {
- const podfilePropertiesPath = path.join(config.modRequest.platformProjectRoot, 'Podfile.properties.json');
-
- let properties = {};
- if (fs.existsSync(podfilePropertiesPath)) {
- try {
- properties = JSON.parse(fs.readFileSync(podfilePropertiesPath, 'utf8'));
- } catch {
- // If file exists but is invalid JSON, start fresh
- }
- }
-
- // Set the iOS deployment target
- if (
- !properties['ios.deploymentTarget'] ||
- parseFloat(properties['ios.deploymentTarget']) < parseFloat(CLERK_MIN_IOS_VERSION)
- ) {
- properties['ios.deploymentTarget'] = CLERK_MIN_IOS_VERSION;
- fs.writeFileSync(podfilePropertiesPath, JSON.stringify(properties, null, 2) + '\n');
- console.log(`✅ Set ios.deploymentTarget to ${CLERK_MIN_IOS_VERSION} in Podfile.properties.json`);
- }
-
- return config;
- },
- ]);
-
- // First update the iOS deployment target to 17.0 (required by Clerk iOS SDK)
- config = withXcodeProject(config, config => {
- const xcodeProject = config.modResults;
-
- try {
- // Update deployment target in all build configurations
- const buildConfigs = xcodeProject.hash.project.objects.XCBuildConfiguration || {};
-
- for (const [uuid, buildConfig] of Object.entries(buildConfigs)) {
- if (buildConfig && buildConfig.buildSettings) {
- const currentTarget = buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET;
- if (currentTarget && parseFloat(currentTarget) < parseFloat(CLERK_MIN_IOS_VERSION)) {
- buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET = CLERK_MIN_IOS_VERSION;
- }
- }
- }
-
- console.log(`✅ Updated iOS deployment target to ${CLERK_MIN_IOS_VERSION}`);
- } catch (error) {
- console.error('❌ Error updating deployment target:', error.message);
- }
-
- return config;
- });
-
- config = withInfoPlist(config, modConfig => {
- modConfig.modResults.ClerkExpoVersion = packageJson.version;
- return modConfig;
- });
-
- return config;
-};
-
-/**
- * Add packaging exclusions to Android app build.gradle to resolve
- * duplicate META-INF file conflicts from clerk-android dependencies.
- */
-const withClerkAndroid = config => {
- console.log('✅ Clerk Android plugin loaded');
-
- config = withAndroidManifest(config, modConfig => {
+const withClerkHostedAuthCallback = config => {
+ return withAndroidManifest(config, modConfig => {
const packageName = config.android?.package;
if (packageName) {
const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(modConfig.modResults);
@@ -133,83 +49,6 @@ const withClerkAndroid = config => {
}
return modConfig;
});
-
- return withAppBuildGradle(config, modConfig => {
- let buildGradle = modConfig.modResults.contents;
-
- // --- META-INF exclusion ---
- if (!buildGradle.includes('META-INF/versions/9/OSGI-INF/MANIFEST.MF')) {
- // AGP 8+ uses `packaging` DSL, older versions use `packagingOptions`
- const packagingMatch = buildGradle.match(/packaging\s*\{/) || buildGradle.match(/packagingOptions\s*\{/);
- if (packagingMatch) {
- const blockName = packagingMatch[0].trim().replace(/\s*\{$/, '');
- const resourcesExclude = `${blockName} {
- // Clerk Android SDK: exclude duplicate META-INF files
- resources {
- excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
- }`;
-
- buildGradle = buildGradle.replace(new RegExp(`${blockName}\\s*\\{`), resourcesExclude);
- } else {
- // No packaging block found; append one at the end of the android block
- const androidBlockEnd = buildGradle.lastIndexOf('}');
- if (androidBlockEnd !== -1) {
- const packagingBlock = `\n packaging {\n resources {\n excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']\n }\n }\n`;
- buildGradle = buildGradle.slice(0, androidBlockEnd) + packagingBlock + buildGradle.slice(androidBlockEnd);
- }
- }
- console.log('✅ Clerk Android packaging exclusions added');
- }
-
- // --- Kotlin metadata version check skip ---
- if (!buildGradle.includes('-Xskip-metadata-version-check')) {
- const kotlinOptionsMatch = buildGradle.match(/kotlinOptions\s*\{/);
- if (kotlinOptionsMatch) {
- buildGradle = buildGradle.replace(
- /kotlinOptions\s*\{/,
- `kotlinOptions {\n // Clerk: allow reading metadata from newer Kotlin versions\n freeCompilerArgs += ['-Xskip-metadata-version-check']`,
- );
- } else {
- const androidMatch = buildGradle.match(/android\s*\{/);
- if (androidMatch) {
- buildGradle = buildGradle.replace(
- /android\s*\{/,
- `android {\n kotlinOptions {\n // Clerk: allow reading metadata from newer Kotlin versions\n freeCompilerArgs += ['-Xskip-metadata-version-check']\n }`,
- );
- }
- }
- console.log('✅ Clerk Android Kotlin metadata version check skip added');
- }
-
- modConfig.modResults.contents = buildGradle;
- return modConfig;
- });
-};
-
-/**
- * Combined Clerk Expo plugin
- *
- * When this plugin is configured in app.json/app.config.js:
- * 1. iOS gets the deployment target and metadata required by Clerk native views
- * 2. Android gets packaging exclusions for dependency conflicts
- *
- * Native modules and views are registered via Expo Modules autolinking.
- */
-/**
- * Write ClerkKeychainService to Info.plist when keychainService is provided.
- * This allows extension apps (watch, widget, app clip) to share the same
- * keychain entry as the main app by using a custom service identifier.
- */
-const withClerkKeychainService = (config, { keychainService } = {}) => {
- if (!keychainService) {
- return config;
- }
-
- return withInfoPlist(config, modConfig => {
- modConfig.modResults.ClerkKeychainService = keychainService;
- console.log(`✅ Set ClerkKeychainService in Info.plist: ${keychainService}`);
- return modConfig;
- });
};
const withClerkFaceIDPermission = (config, { faceIDPermission } = {}) => {
@@ -243,145 +82,75 @@ const withClerkAppleSignIn = config => {
});
};
-/**
- * Apply a custom theme to Clerk native components (iOS + Android).
- *
- * Accepts a `theme` prop pointing to a JSON file with optional keys:
- * - colors: { primary, background, input, danger, success, warning,
- * foreground, mutedForeground, primaryForeground, inputForeground,
- * neutral, border, ring, muted, shadow, secondaryButtonBackground,
- * secondaryButtonForeground } (hex color strings)
- * - darkColors: same keys as colors (for dark mode)
- * - design: { fontFamily: string, borderRadius: number }
- *
- * iOS: Embeds the parsed JSON into Info.plist under key "ClerkTheme".
- * Android: Copies the JSON file to android/app/src/main/assets/clerk_theme.json.
- */
-const VALID_COLOR_KEYS = [
- 'primary',
- 'background',
- 'input',
- 'danger',
- 'success',
- 'warning',
- 'foreground',
- 'mutedForeground',
- 'primaryForeground',
- 'inputForeground',
- 'neutral',
- 'border',
- 'ring',
- 'muted',
- 'shadow',
- 'secondaryButtonBackground',
- 'secondaryButtonForeground',
-];
-
-const HEX_COLOR_REGEX = /^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
-
-function isPlainObject(value) {
- return typeof value === 'object' && value !== null && !Array.isArray(value);
-}
-
-function validateThemeJson(theme) {
- if (!isPlainObject(theme)) {
- throw new Error('Clerk theme: theme JSON must be a plain object');
+const resolveClerkExpoNativePlugin = config => {
+ try {
+ const paths = [config._internal?.projectRoot, process.cwd()].filter(Boolean);
+ return require(require.resolve(`${CLERK_EXPO_NATIVE}/app.plugin.js`, { paths }));
+ } catch {
+ return null;
}
+};
- const validateColors = (colors, label) => {
- if (!isPlainObject(colors)) {
- throw new Error(`Clerk theme: ${label} must be an object`);
- }
- for (const [key, value] of Object.entries(colors)) {
- if (!VALID_COLOR_KEYS.includes(key)) {
- console.warn(`⚠️ Clerk theme: unknown color key "${key}" in ${label}, ignoring`);
- continue;
- }
- if (typeof value !== 'string' || !HEX_COLOR_REGEX.test(value)) {
- throw new Error(`Clerk theme: invalid hex color for ${label}.${key}: "${value}"`);
- }
- }
- };
-
- if (theme.colors != null) validateColors(theme.colors, 'colors');
- if (theme.darkColors != null) validateColors(theme.darkColors, 'darkColors');
-
- if (theme.design != null) {
- if (!isPlainObject(theme.design)) {
- throw new Error(`Clerk theme: design must be an object`);
+const getListedPluginProps = (config, name) => {
+ for (const entry of config.plugins || []) {
+ if (entry === name) {
+ return {};
}
- if (theme.design.fontFamily != null && typeof theme.design.fontFamily !== 'string') {
- throw new Error(`Clerk theme: design.fontFamily must be a string`);
- }
- if (theme.design.borderRadius != null && typeof theme.design.borderRadius !== 'number') {
- throw new Error(`Clerk theme: design.borderRadius must be a number`);
+ if (Array.isArray(entry) && entry[0] === name) {
+ return entry[1] || {};
}
}
-}
-
-const withClerkTheme = (config, props = {}) => {
- const { theme } = props;
- if (!theme) return config;
+ return {};
+};
- // Resolve the theme file path relative to the project root
- const themePath = path.resolve(theme);
- if (!fs.existsSync(themePath)) {
- console.warn(`⚠️ Clerk theme file not found: ${themePath}, skipping theme`);
+/**
+ * Apply the @clerk/expo-native-components config plugin when it is installed, so apps that only list
+ * "@clerk/expo" keep the iOS deployment target and native SDK configuration they need.
+ */
+const withClerkExpoNativeComponents = (config, props = {}, resolvePlugin = resolveClerkExpoNativePlugin) => {
+ const nativeProps = Object.fromEntries(
+ CLERK_EXPO_NATIVE_OPTIONS.filter(option => props[option] !== undefined).map(option => [option, props[option]]),
+ );
+ const nativeOptionNames = Object.keys(nativeProps)
+ .map(option => `"${option}"`)
+ .join(', ');
+
+ if (config._internal?.pluginHistory?.[CLERK_EXPO_NATIVE]) {
+ if (nativeOptionNames) {
+ console.warn(
+ `⚠️ Clerk: The following "@clerk/expo" plugin options are ignored because the "${CLERK_EXPO_NATIVE}" plugin already ran: ${nativeOptionNames}. Pass them to the "${CLERK_EXPO_NATIVE}" plugin instead.`,
+ );
+ }
return config;
}
- let themeJson;
- try {
- themeJson = JSON.parse(fs.readFileSync(themePath, 'utf8'));
- validateThemeJson(themeJson);
- } catch (e) {
- throw new Error(`Clerk theme: failed to parse ${themePath}: ${e.message}`);
+ const clerkExpoNativePlugin = resolvePlugin(config);
+ if (!clerkExpoNativePlugin) {
+ if (nativeOptionNames) {
+ console.warn(
+ `⚠️ Clerk: The following "@clerk/expo" plugin options require ${CLERK_EXPO_NATIVE} and are ignored: ${nativeOptionNames}. Install it with \`npx expo install ${CLERK_EXPO_NATIVE}\` and add "${CLERK_EXPO_NATIVE}" to the plugins array in your app config.`,
+ );
+ }
+ return config;
}
- // iOS: Embed theme in Info.plist under "ClerkTheme"
- config = withInfoPlist(config, modConfig => {
- modConfig.modResults.ClerkTheme = themeJson;
- console.log('✅ Embedded Clerk theme in Info.plist');
- return modConfig;
- });
-
- // Android: Copy theme JSON to assets
- config = withDangerousMod(config, [
- 'android',
- async config => {
- const assetsDir = path.join(config.modRequest.platformProjectRoot, 'app', 'src', 'main', 'assets');
- if (!fs.existsSync(assetsDir)) {
- fs.mkdirSync(assetsDir, { recursive: true });
- }
- const destPath = path.join(assetsDir, 'clerk_theme.json');
- fs.writeFileSync(destPath, JSON.stringify(themeJson, null, 2) + '\n');
- console.log('✅ Copied Clerk theme to Android assets');
- return config;
- },
- ]);
-
- return config;
+ return clerkExpoNativePlugin(config, { ...nativeProps, ...getListedPluginProps(config, CLERK_EXPO_NATIVE) });
};
const withClerkExpo = (config, props = {}) => {
const { appleSignIn = true } = props;
- config = withClerkIOS(config);
if (appleSignIn !== false) {
config = withClerkAppleSignIn(config);
}
- config = withClerkAndroid(config);
- config = withClerkKeychainService(config, props);
+ config = withClerkHostedAuthCallback(config);
config = withClerkFaceIDPermission(config, props);
- config = withClerkTheme(config, props);
+ config = withClerkExpoNativeComponents(config, props);
return config;
};
module.exports = withClerkExpo;
module.exports._testing = {
addHostedAuthIntentFilter,
+ withClerkExpoNativeComponents,
withClerkFaceIDPermission,
- validateThemeJson,
- isPlainObject,
- VALID_COLOR_KEYS,
- HEX_COLOR_REGEX,
};
diff --git a/packages/expo/native/index.d.ts b/packages/expo/native/index.d.ts
new file mode 100644
index 00000000000..9d8fb781ea9
--- /dev/null
+++ b/packages/expo/native/index.d.ts
@@ -0,0 +1 @@
+export * from '@clerk/expo-native-components';
diff --git a/packages/expo/native/package.json b/packages/expo/native/package.json
index 6ae24b71af4..928da2cc7c8 100644
--- a/packages/expo/native/package.json
+++ b/packages/expo/native/package.json
@@ -1,4 +1,4 @@
{
"main": "../dist/native/index.js",
- "types": "../dist/native/index.d.ts"
+ "types": "./index.d.ts"
}
diff --git a/packages/expo/package.json b/packages/expo/package.json
index 3ed72b4c40c..738d9ebf47e 100644
--- a/packages/expo/package.json
+++ b/packages/expo/package.json
@@ -30,7 +30,7 @@
},
"./app.plugin.js": "./app.plugin.js",
"./native": {
- "types": "./dist/native/index.d.ts",
+ "types": "./native/index.d.ts",
"default": "./dist/native/index.js"
},
"./web": {
@@ -85,8 +85,6 @@
"types": "./dist/index.d.ts",
"files": [
"dist",
- "android",
- "ios",
"native",
"web",
"local-credentials",
@@ -99,9 +97,6 @@
"hosted-auth",
"experimental",
"legacy",
- "src/specs",
- "expo-module.config.json",
- "react-native.config.js",
"app.plugin.js",
"app.plugin.d.ts"
],
@@ -128,6 +123,7 @@
"devDependencies": {
"@clerk/expo-biometrics": "workspace:*",
"@clerk/expo-google-signin": "workspace:*",
+ "@clerk/expo-native-components": "workspace:*",
"@clerk/expo-passkeys": "workspace:*",
"@expo/config-plugins": "^54.0.4",
"@types/base-64": "^1.0.2",
@@ -144,6 +140,7 @@
"peerDependencies": {
"@clerk/expo-biometrics": ">=0.1.0",
"@clerk/expo-google-signin": ">=0.1.0",
+ "@clerk/expo-native-components": ">=0.1.0",
"@clerk/expo-passkeys": ">=0.0.6",
"expo": "catalog:peer-expo",
"expo-apple-authentication": ">=7.0.0",
@@ -164,6 +161,9 @@
"@clerk/expo-google-signin": {
"optional": true
},
+ "@clerk/expo-native-components": {
+ "optional": true
+ },
"@clerk/expo-passkeys": {
"optional": true
},
@@ -197,10 +197,5 @@
},
"publishConfig": {
"access": "public"
- },
- "codegenConfig": {
- "name": "ClerkExpoSpec",
- "type": "all",
- "jsSrcsDir": "src/specs"
}
}
diff --git a/packages/expo/src/__tests__/appPlugin.test.js b/packages/expo/src/__tests__/appPlugin.test.js
new file mode 100644
index 00000000000..55f9e5abdc3
--- /dev/null
+++ b/packages/expo/src/__tests__/appPlugin.test.js
@@ -0,0 +1,128 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export
+const { withClerkExpoNativeComponents, withClerkFaceIDPermission } = require('../../app.plugin.js')._testing;
+
+function applyInfoPlistMod(config, modResults) {
+ return config.mods.ios.infoPlist({
+ ...config,
+ modRequest: {},
+ modResults,
+ });
+}
+
+describe('withClerkFaceIDPermission', () => {
+ test('adds the configured Face ID usage description', async () => {
+ const config = withClerkFaceIDPermission(
+ { name: 'test', slug: 'test' },
+ { faceIDPermission: 'Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.' },
+ );
+
+ const result = await applyInfoPlistMod(config, {});
+
+ expect(result.modResults.NSFaceIDUsageDescription).toBe('Allow $(PRODUCT_NAME) to use Face ID for secure sign-in.');
+ });
+
+ test('preserves an app-provided Face ID usage description', async () => {
+ const config = withClerkFaceIDPermission(
+ { name: 'test', slug: 'test' },
+ { faceIDPermission: 'Clerk-provided description' },
+ );
+
+ const result = await applyInfoPlistMod(config, {
+ NSFaceIDUsageDescription: 'App-provided description',
+ });
+
+ expect(result.modResults.NSFaceIDUsageDescription).toBe('App-provided description');
+ });
+
+ test('does not configure the Info.plist without an explicit permission description', () => {
+ const config = { name: 'test', slug: 'test' };
+
+ expect(withClerkFaceIDPermission(config)).toBe(config);
+ expect(config).not.toHaveProperty('mods');
+ });
+
+ test.each([null, '', ' ', true])('rejects an invalid permission description: %j', faceIDPermission => {
+ expect(() => withClerkFaceIDPermission({ name: 'test', slug: 'test' }, { faceIDPermission })).toThrow(
+ 'faceIDPermission must be a non-empty string',
+ );
+ });
+});
+
+describe('withClerkExpoNativeComponents', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ test('applies the @clerk/expo-native-components plugin with its options when it is installed', () => {
+ const nativePlugin = vi.fn(config => ({ ...config, applied: true }));
+ const config = { name: 'test', slug: 'test' };
+
+ const result = withClerkExpoNativeComponents(
+ config,
+ { keychainService: 'com.example.shared', theme: './theme.json', appleSignIn: false },
+ () => nativePlugin,
+ );
+
+ expect(result.applied).toBe(true);
+ expect(nativePlugin).toHaveBeenCalledWith(config, { keychainService: 'com.example.shared', theme: './theme.json' });
+ });
+
+ test('prefers options passed to an explicitly listed @clerk/expo-native-components plugin', () => {
+ const nativePlugin = vi.fn(config => config);
+ const config = {
+ name: 'test',
+ slug: 'test',
+ plugins: ['@clerk/expo', ['@clerk/expo-native-components', { theme: './native-theme.json' }]],
+ };
+
+ withClerkExpoNativeComponents(
+ config,
+ { keychainService: 'com.example.shared', theme: './theme.json' },
+ () => nativePlugin,
+ );
+
+ expect(nativePlugin).toHaveBeenCalledWith(config, {
+ keychainService: 'com.example.shared',
+ theme: './native-theme.json',
+ });
+ });
+
+ test('does not apply the @clerk/expo-native-components plugin twice', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const nativePlugin = vi.fn(config => config);
+ const config = {
+ name: 'test',
+ slug: 'test',
+ _internal: {
+ pluginHistory: { '@clerk/expo-native-components': { name: '@clerk/expo-native-components', version: '0.0.1' } },
+ },
+ };
+
+ expect(withClerkExpoNativeComponents(config, {}, () => nativePlugin)).toBe(config);
+ expect(nativePlugin).not.toHaveBeenCalled();
+ expect(warn).not.toHaveBeenCalled();
+
+ withClerkExpoNativeComponents(config, { theme: './theme.json' }, () => nativePlugin);
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('Pass them to the "@clerk/expo-native-components" plugin instead'),
+ );
+ });
+
+ test('leaves the config untouched when @clerk/expo-native-components is not installed', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const config = { name: 'test', slug: 'test' };
+
+ expect(withClerkExpoNativeComponents(config, {}, () => null)).toBe(config);
+ expect(warn).not.toHaveBeenCalled();
+ });
+
+ test('warns with install instructions when native-only options are passed without @clerk/expo-native-components', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const config = { name: 'test', slug: 'test' };
+
+ expect(withClerkExpoNativeComponents(config, { keychainService: 'com.example.shared' }, () => null)).toBe(config);
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('npx expo install @clerk/expo-native-components'));
+ });
+});
diff --git a/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts b/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts
index cc1c6695b5a..a66696b3d3e 100644
--- a/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts
+++ b/packages/expo/src/biometric-credentials/__tests__/reverification.test.ts
@@ -222,7 +222,7 @@ test('rejects completion if the active session changes while refreshing its toke
expect(clerk.setActive).not.toHaveBeenCalled();
});
-test('explains the missing reverification method in older @clerk/expo builds', async () => {
+test('explains the missing reverification method in older @clerk/expo-native-components builds', async () => {
const reverify = mocks.nativeModule.reverifyWithBiometrics;
Object.assign(mocks.nativeModule, { reverifyWithBiometrics: undefined });
try {
@@ -233,12 +233,12 @@ test('explains the missing reverification method in older @clerk/expo builds', a
}
});
-test('explains that reverification requires a development build', async () => {
+test('explains how to install @clerk/expo-native-components', async () => {
mocks.isNativeModuleInstalled = false;
try {
const { result } = renderHook(useIosBiometrics);
await expect(result.current.reverify()).rejects.toThrow(
- 'Biometric reverification requires a development build containing a compatible version of @clerk/expo.',
+ 'Biometric reverification requires the @clerk/expo-native-components package in a development build. Install it with `npx expo install @clerk/expo-native-components`',
);
} finally {
mocks.isNativeModuleInstalled = true;
diff --git a/packages/expo/src/biometric-credentials/nativeReverification.ts b/packages/expo/src/biometric-credentials/nativeReverification.ts
index e3435e85654..9418931a140 100644
--- a/packages/expo/src/biometric-credentials/nativeReverification.ts
+++ b/packages/expo/src/biometric-credentials/nativeReverification.ts
@@ -5,12 +5,20 @@ import { errorThrower } from '../utils/errors';
import { ClerkExpoModule } from '../utils/native-module';
import type { UseBiometricCredentialsReturn } from './types';
+const CLERK_EXPO_NATIVE_INSTALL_INSTRUCTIONS =
+ 'Install it with `npx expo install @clerk/expo-native-components`, add "@clerk/expo-native-components" to the plugins array in your app config, then rebuild your development build.';
+
export function createNativeReverify(clerk: ReturnType): UseBiometricCredentialsReturn['reverify'] {
return async params => {
const nativeModule = ClerkExpoModule;
- if (typeof nativeModule?.reverifyWithBiometrics !== 'function') {
+ if (!nativeModule) {
+ return errorThrower.throw(
+ `Biometric reverification requires the @clerk/expo-native-components package in a development build. ${CLERK_EXPO_NATIVE_INSTALL_INSTRUCTIONS}`,
+ );
+ }
+ if (typeof nativeModule.reverifyWithBiometrics !== 'function') {
return errorThrower.throw(
- 'Biometric reverification requires a development build containing a compatible version of @clerk/expo.',
+ 'Biometric reverification requires a development build containing a compatible version of @clerk/expo-native-components.',
);
}
const level = params?.level ?? 'first_factor';
diff --git a/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts b/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts
index e5f6197b18b..965c2654da3 100644
--- a/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts
+++ b/packages/expo/src/biometric-credentials/useBiometricCredentials.shared.ts
@@ -8,7 +8,7 @@ import type { UseBiometricCredentialsReturn } from './types';
* Accesses biometric credential enrollment, sign-in, and session reverification on iOS and Android.
*
* Enrollment and sign-in require the `@clerk/expo-biometrics` package, which keeps the private key on the device.
- * Session reverification uses Clerk's native SDK and requires a development build.
+ * Session reverification requires the `@clerk/expo-native-components` package.
*/
export function useBiometricCredentials(): UseBiometricCredentialsReturn {
const clerk = useClerk();
diff --git a/packages/expo/src/native/__tests__/index.test.ts b/packages/expo/src/native/__tests__/index.test.ts
new file mode 100644
index 00000000000..f7904bb2a8b
--- /dev/null
+++ b/packages/expo/src/native/__tests__/index.test.ts
@@ -0,0 +1,57 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+const importNativeEntry = () => import('../index');
+
+describe('@clerk/expo/native', () => {
+ afterEach(() => {
+ vi.doUnmock('../loadClerkExpoNative');
+ vi.resetModules();
+ vi.restoreAllMocks();
+ });
+
+ describe('when @clerk/expo-native-components is not installed', () => {
+ beforeEach(() => {
+ vi.doMock('../loadClerkExpoNative', () => ({ loadClerkExpoNative: () => null }));
+ });
+
+ test.each(['AuthView', 'UserButton', 'UserProfileView'] as const)(
+ 'rendering %s throws an error with install instructions',
+ async name => {
+ const Component = (await importNativeEntry())[name] as (props: object) => unknown;
+
+ expect(() => Component({})).toThrow(
+ /`.+` is unavailable\. Native components have moved to the @clerk\/expo-native-components package\. Install it with `npx expo install @clerk\/expo-native-components`/,
+ );
+ },
+ );
+
+ test.each(['useAuthViewState', 'useUserProfileCustomPageNavigation'] as const)(
+ 'calling %s throws an error with install instructions',
+ async name => {
+ const hook = (await importNativeEntry())[name] as () => unknown;
+
+ expect(() => hook()).toThrow(`\`${name}\` is unavailable.`);
+ expect(() => hook()).toThrow('add "@clerk/expo-native-components" to the plugins array in your app config');
+ },
+ );
+ });
+
+ test('re-exports @clerk/expo-native-components when it is installed', async () => {
+ const clerkExpoNative = {
+ AuthView: vi.fn(() => null),
+ UserButton: vi.fn(() => null),
+ UserProfileView: vi.fn(() => null),
+ useAuthViewState: vi.fn(),
+ useUserProfileCustomPageNavigation: vi.fn(),
+ };
+ vi.doMock('../loadClerkExpoNative', () => ({ loadClerkExpoNative: () => clerkExpoNative }));
+
+ const nativeEntry = await importNativeEntry();
+
+ expect(nativeEntry.AuthView).toBe(clerkExpoNative.AuthView);
+ expect(nativeEntry.UserButton).toBe(clerkExpoNative.UserButton);
+ expect(nativeEntry.UserProfileView).toBe(clerkExpoNative.UserProfileView);
+ expect(nativeEntry.useAuthViewState).toBe(clerkExpoNative.useAuthViewState);
+ expect(nativeEntry.useUserProfileCustomPageNavigation).toBe(clerkExpoNative.useUserProfileCustomPageNavigation);
+ });
+});
diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts
index 2a62047dd85..67acaa9ab68 100644
--- a/packages/expo/src/native/index.ts
+++ b/packages/expo/src/native/index.ts
@@ -1,49 +1,27 @@
-/**
- * Native UI components for Clerk authentication in Expo apps.
- *
- * These components provide pre-built, native authentication experiences powered by:
- * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios
- * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android
- *
- * ## Installation
- *
- * Native components require the `@clerk/expo` plugin to be configured in your `app.json`:
- *
- * ```json
- * {
- * "expo": {
- * "plugins": ["@clerk/expo"]
- * }
- * }
- * ```
- *
- * Then run `npx expo prebuild` to generate native code.
- *
- * ## Components
- *
- * - {@link AuthView} - Authentication flow (sign-in/sign-up), renders inline
- * - {@link UserProfileView} - User profile and account management, renders inline
- * - {@link UserButton} - Avatar button that opens the native user profile
- *
- * @module @clerk/expo/native
- */
+import { errorThrower } from '../errorThrower';
+import type { ClerkExpoNativeModule } from './loadClerkExpoNative';
+import { loadClerkExpoNative } from './loadClerkExpoNative';
-export { AuthView } from './AuthView';
-export type { AuthViewProps, AuthViewMode } from './AuthView.types';
-export type { EmbeddedNavigationProps } from './EmbeddedNavigation.types';
-export { useAuthViewState } from './useAuthViewState';
-export type { UseAuthViewStateReturn } from './useAuthViewState';
-export { UserButton } from './UserButton';
-export type { UserButtonProps, UserButtonUserProfileProps } from './UserButton';
-export { useUserProfileCustomPageNavigation } from './UserProfileCustomPages';
-export type {
- UserProfileCustomPageNavigation,
- UserProfileCustomDestination,
- UserProfileCustomPage,
- UserProfileCustomPageIcon,
- UserProfileCustomPagePlacement,
- UserProfileRow,
- UserProfileSection,
-} from './UserProfileCustomPages';
-export { UserProfileView } from './UserProfileView';
-export type { UserProfileViewProps } from './UserProfileView';
+// Public types are declared in native/index.d.ts, which re-exports them from @clerk/expo-native-components.
+
+const CLERK_EXPO_NATIVE_MISSING_MESSAGE =
+ 'Native components have moved to the @clerk/expo-native-components package. ' +
+ 'Install it with `npx expo install @clerk/expo-native-components`, add "@clerk/expo-native-components" to the plugins array in your app config, ' +
+ 'then rebuild your native app. You can then import them from "@clerk/expo-native-components".';
+
+const clerkExpoNative = loadClerkExpoNative();
+
+function resolveExport(name: K) {
+ return (
+ clerkExpoNative?.[name] ??
+ function ClerkExpoNativeMissing(): never {
+ return errorThrower.throw(`\`${name}\` is unavailable. ${CLERK_EXPO_NATIVE_MISSING_MESSAGE}`);
+ }
+ );
+}
+
+export const AuthView = resolveExport('AuthView');
+export const UserButton = resolveExport('UserButton');
+export const UserProfileView = resolveExport('UserProfileView');
+export const useAuthViewState = resolveExport('useAuthViewState');
+export const useUserProfileCustomPageNavigation = resolveExport('useUserProfileCustomPageNavigation');
diff --git a/packages/expo/src/native/loadClerkExpoNative.ts b/packages/expo/src/native/loadClerkExpoNative.ts
new file mode 100644
index 00000000000..194b0bed1d9
--- /dev/null
+++ b/packages/expo/src/native/loadClerkExpoNative.ts
@@ -0,0 +1,18 @@
+type ClerkExpoNativeExport = (...args: never[]) => unknown;
+
+export type ClerkExpoNativeModule = Partial<
+ Record<
+ 'AuthView' | 'UserButton' | 'UserProfileView' | 'useAuthViewState' | 'useUserProfileCustomPageNavigation',
+ ClerkExpoNativeExport
+ >
+>;
+
+export function loadClerkExpoNative(): ClerkExpoNativeModule | null {
+ try {
+ // Synchronous require() in try/catch so Metro treats @clerk/expo-native-components as an optional dependency.
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ return require('@clerk/expo-native-components') as ClerkExpoNativeModule;
+ } catch {
+ return null;
+ }
+}
diff --git a/packages/expo/src/provider/nativeClientSync.tsx b/packages/expo/src/provider/nativeClientSync.tsx
index 5f40ed5287c..5cdb87dc6ae 100644
--- a/packages/expo/src/provider/nativeClientSync.tsx
+++ b/packages/expo/src/provider/nativeClientSync.tsx
@@ -1163,7 +1163,7 @@ export function useNativeClientBootstrap({
if (__DEV__) {
console.debug(
`[ClerkProvider] Native Clerk module not available. ` +
- `To enable native features, add "@clerk/expo" to your app.json plugins array.`,
+ `To enable native features, install @clerk/expo-native-components and add "@clerk/expo-native-components" to your app.json plugins array.`,
);
}
} else if (__DEV__) {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8c0b159a61a..1fea7bc2705 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -668,6 +668,9 @@ importers:
'@clerk/expo-google-signin':
specifier: workspace:*
version: link:../expo-google-signin
+ '@clerk/expo-native-components':
+ specifier: workspace:*
+ version: link:../expo-native-components
'@clerk/expo-passkeys':
specifier: workspace:*
version: link:../expo-passkeys
@@ -720,6 +723,28 @@ importers:
specifier: ~54.0.36
version: 54.0.36(@babel/core@7.29.7)(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ packages/expo-native-components:
+ dependencies:
+ '@clerk/react':
+ specifier: workspace:^
+ version: link:../react
+ expo:
+ specifier: catalog:peer-expo
+ version: 54.0.36(@babel/core@7.29.7)(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10)
+ react:
+ specifier: 18.3.1
+ version: 18.3.1
+ tslib:
+ specifier: catalog:repo
+ version: 2.8.1
+ devDependencies:
+ '@expo/config-plugins':
+ specifier: ^54.0.4
+ version: 54.0.5
+ react-native:
+ specifier: ^0.86.0
+ version: 0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10)
+
packages/expo-passkeys:
dependencies:
'@clerk/shared':