
Expo Go or Development Build? Building Production-Ready Authentication with Clerk
Mobile authentication is where most Expo developers hit their first wall. OAuth redirects fail silently, sessions vanish between app restarts, and the gap between "works in development" and "works in production" is wider than expected.
This guide walks through building a fully working Expo app with Clerk authentication. You'll set up Google native sign-in, browser-based Google and GitHub OAuth, email OTP, protected routes, and production builds you can share via TestFlight.
The answer to "Expo Go or development build?" turns out to be more interesting than a simple either/or. Clerk offers three tiers of Expo integration, and the right choice depends on what authentication methods your app needs. If you want to follow along with a working reference, check out the Clerk Expo quickstart and the clerk-expo-quickstart repository.
Expo Go vs development builds: what actually matters for authentication
Developers searching for "Expo Go vs development build" have usually just hit a wall with OAuth redirects. Here's what's actually going on and why the real answer involves three approaches, not two.
What Expo Go can and can't do
Expo Go is a pre-built native app that runs your JavaScript bundle. It's great for rapid prototyping, but it has limitations that matter for auth.
The big one: Expo Go can't register custom URL schemes. When Google's OAuth flow tries to redirect back to your app via myapp://callback, there's no myapp:// scheme registered. The redirect fails silently or lands nowhere. Expo Go also can't load custom native modules, which rules out native Google Sign-In (it uses a TurboModule under the hood). Deep links in Expo Go use the /--/ prefix format, which doesn't work with standard OAuth callback patterns.
What does work in Expo Go: email/password with custom sign-in forms, basic session management with useAuth(), the Show component for conditional rendering, and any JavaScript-only auth flow that doesn't need native modules or custom URL schemes.
Why development builds solve the OAuth problem
A development build is your own native app with a development experience bolted on. You compile the native code yourself (or let EAS Build do it), which means custom URL schemes, native modules, and deep linking all work.
Under the hood, expo-dev-client gives you the dev menu, hot reload, and bundle server switching that Expo Go provides, but inside your app with your native configuration. The fundamental distinction is that Expo Go uses Expo's native bundle while a development build uses yours.
Continuous Native Generation (CNG) via npx expo prebuild generates the ios/ and android/ directories from your app.json config and plugins. Config plugins like @clerk/expo automatically wire up native entitlements for features like Apple Sign-In and native Google Sign-In.
The three-tier reality
Clerk's Expo SDK offers three approaches, not two:
This article builds with the native components approach (least code, best UX) and also shows the browser-based OAuth approach for GitHub (since native sign-in isn't available for all providers). If you're just prototyping email/password auth, Expo Go works fine. Switch to a development build when you add OAuth or native sign-in.
Setting up the project
Prerequisites
Before you start, make sure you have:
- Node.js 20.9.0+ (Clerk Core 3 requirement)
- Expo CLI (
npx expo) - EAS CLI (
npm install -g eas-cli) for production builds later - Xcode (iOS) or Android Studio (Android) for local development builds
- A Clerk account (free tier supports 50,000 monthly retained users and unlimited applications)
- Apple Developer Program ($99/year) if you want to test on physical iOS devices or distribute via TestFlight. Simulator builds work without the paid account.
This tutorial targets Expo SDK 55 (current stable, React Native 0.83). The minimum requirement for Clerk Core 3 is SDK 53. At the time of writing, the App Store and Play Store versions of Expo Go run SDK 54. You can install SDK 55 Expo Go via CLI on Android or use the TestFlight beta on iOS, but development builds are the most reliable path for SDK 55 and are required for the OAuth and native features covered here.
Creating the Expo project
Create a new project with Expo Router for file-based routing:
npx create-expo-app@latest clerk-auth-demo
cd clerk-auth-demoInstalling Clerk and dependencies
Install the required packages:
npx expo install @clerk/expo expo-secure-store expo-web-browser expo-auth-session expo-cryptoHere's what each package does:
@clerk/expo: The Clerk SDK (Core 3). This package was renamed from@clerk/clerk-expoin Core 3.expo-secure-store: Encrypted token storage using iOS Keychain and Android Keystore.expo-web-browser: Opens an in-app browser for browser-based OAuth flows.expo-auth-session: Generates OAuth redirect URIs with the correct scheme.expo-crypto: Peer dependency required for theuseSignInWithGoogle()hook. Not needed if you only use AuthView.
Next, configure the @clerk/expo plugin and a custom URL scheme in app.json:
{
"expo": {
"plugins": ["@clerk/expo"],
"scheme": "clerk-auth-demo"
}
}The @clerk/expo config plugin automatically sets up Apple Sign-In entitlements and the native Google Sign-In TurboModule during prebuild. The scheme field registers a custom URL scheme for OAuth redirects.
Creating your first development build
Run the following command to create a local development build:
npx expo run:iosFor Android, use npx expo run:android instead. What happens under the hood: Expo runs prebuild to generate native directories from your app.json config and plugins, compiles the native code, and installs the app on your simulator or device. This is a local build. Later, you'll use EAS for cloud builds and production.
Configuring Clerk
Setting up the Clerk Dashboard
Create a new application in the Clerk Dashboard. Enable three authentication methods:
- Email with OTP verification (under Email, Phone, Username)
- Google as a social connection
- GitHub as a social connection
For Google, you'll need custom credentials from Google Cloud Console (covered in the Google native sign-in section). For GitHub, development instances use shared credentials, so no extra setup is needed to get started.
Environment variables and publishable key
Copy the Publishable Key from the Clerk Dashboard and create a .env file in your project root:
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your-key-hereThe EXPO_PUBLIC_ prefix is required because Expo inlines these values at build time. Never put secret keys in EXPO_PUBLIC_ variables since they're embedded in your app bundle and visible to anyone who decompiles it.
Wrapping your app with ClerkProvider
Add <ClerkProvider> in your root layout at app/_layout.tsx:
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { Slot } from 'expo-router'
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!
if (!publishableKey) {
throw new Error('Add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to your .env file')
}
export default function RootLayout() {
return (
<ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
<Slot />
</ClerkProvider>
)
}The tokenCache prop uses expo-secure-store under the hood. On iOS, tokens are stored in the Keychain. On Android, they're stored in SharedPreferences encrypted with the Keystore system. This means sessions persist across app restarts without the user having to sign in again. Clerk session tokens have a 60-second lifetime and are proactively refreshed in the background on a 50-second interval, so your app never blocks on token refresh.
Building authentication with Clerk's native components
@clerk/expo 3.0 ships pre-built native UI components powered by SwiftUI on iOS and Jetpack Compose on Android. They render as truly native views (not web views), handle email OTP, OAuth, passkeys, and multi-factor authentication automatically, and sync sessions back to the JavaScript SDK.
Using AuthView for sign-in and sign-up
AuthView handles the full authentication flow natively. Set mode="signInOrUp" for a single screen that handles both sign-in and sign-up. It automatically renders all auth methods you've enabled in the Dashboard, including email OTP, Google, and GitHub.
Create a sign-in screen at app/(auth)/sign-in.tsx:
import { AuthView } from '@clerk/expo/native'
import { useAuth } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useEffect } from 'react'
import { View, StyleSheet } from 'react-native'
export default function SignInScreen() {
const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
const router = useRouter()
useEffect(() => {
if (isSignedIn) {
router.replace('/(app)')
}
}, [isSignedIn])
return (
<View style={styles.container}>
<AuthView mode="signInOrUp" />
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1 },
})Native components don't use imperative callbacks. Instead, use useAuth() in a useEffect to react to authentication state changes. When isSignedIn becomes true, redirect to the home screen.
Adding the UserButton component
UserButton renders the user's circular avatar. Tapping it opens a native profile modal. It fills its parent container, so wrap it in a View with explicit dimensions.
Add the UserButton to your home screen at app/(app)/index.tsx:
import { UserButton } from '@clerk/expo/native'
import { View, Text, StyleSheet } from 'react-native'
export default function HomeScreen() {
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Home</Text>
<View style={styles.userButton}>
<UserButton />
</View>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24 },
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold' },
userButton: { width: 40, height: 40 },
})UserProfileView for inline profile management
UserProfileView renders a full profile management screen inline: personal info, security settings, connected accounts, account switching, and sign out. Set style={{ flex: 1 }} so it fills the screen.
Create a profile screen at app/(app)/profile.tsx:
import { UserProfileView } from '@clerk/expo/native'
import { useAuth } from '@clerk/expo'
import { useRouter } from 'expo-router'
import { useEffect } from 'react'
export default function ProfileScreen() {
const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
const router = useRouter()
useEffect(() => {
if (isSignedIn === false) {
router.replace('/(auth)/sign-in')
}
}, [isSignedIn])
return <UserProfileView style={{ flex: 1 }} />
}Listen for sign-out via useAuth() and redirect when isSignedIn becomes false.
Setting up OAuth: Google native sign-in
If you're using <AuthView />, Google Sign-In works automatically after Dashboard configuration. You don't need the useSignInWithGoogle() hook or expo-crypto. This section is for developers building custom UI who want the native OS-level account picker.
Configuring Google OAuth in the Clerk Dashboard
Add Google as a social connection with custom credentials. You'll need to create OAuth 2.0 credentials in Google Cloud Console:
- iOS OAuth client ID (Application type: iOS, with your Bundle ID)
- Android OAuth client ID (Application type: Android, with your package name and SHA-1 fingerprint)
- Web OAuth client ID (required for Clerk's backend token verification, even for native-only apps)
Set the Web Client ID and Client Secret in the Clerk Dashboard under Social Connections.
Then register your native app in the Clerk Dashboard under Native Applications:
- iOS: App ID Prefix (Team ID) + Bundle ID
- Android: namespace + package name + SHA-256 fingerprint
Add these environment variables to your .env:
EXPO_PUBLIC_CLERK_GOOGLE_IOS_CLIENT_ID=your-ios-client-id
EXPO_PUBLIC_CLERK_GOOGLE_ANDROID_CLIENT_ID=your-android-client-id
EXPO_PUBLIC_CLERK_GOOGLE_WEB_CLIENT_ID=your-web-client-id
EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME=com.googleusercontent.apps.your-ios-client-idFor the complete step-by-step, see the Sign in with Google guide.
Native Google Sign-In with useSignInWithGoogle()
For custom UI, use the useSignInWithGoogle() hook. It triggers the OS-level account picker without opening a browser.
import { useSignInWithGoogle } from '@clerk/expo/google'
import { TouchableOpacity, Text, Alert, Platform } from 'react-native'
export function GoogleSignInButton() {
const { startGoogleAuthenticationFlow } = useSignInWithGoogle()
const handleGoogleSignIn = async () => {
if (Platform.OS === 'web') {
Alert.alert('Not supported', 'Native Google Sign-In is not available on web.')
return
}
try {
const { createdSessionId, setActive } = await startGoogleAuthenticationFlow()
if (createdSessionId && setActive) {
await setActive({ session: createdSessionId })
}
} catch (err: any) {
// Error code -5 or SIGN_IN_CANCELLED means the user dismissed the picker
if (err.code === 'SIGN_IN_CANCELLED' || err.code === '-5') {
return
}
Alert.alert('Error', 'Failed to sign in with Google.')
}
}
return (
<TouchableOpacity onPress={handleGoogleSignIn}>
<Text>Sign in with Google</Text>
</TouchableOpacity>
)
}Testing native Google Sign-In
Google Sign-In works on both simulators and physical devices with development builds. After any environment variable or config change, rebuild with npx expo run:ios. Common issues include missing client IDs, wrong bundle ID in Google Cloud Console, and forgetting to rebuild after changing config.
Setting up OAuth: browser-based Google and GitHub
How browser-based OAuth differs from native
Browser-based OAuth opens an in-app browser (via expo-web-browser), the user authenticates on the provider's website, and the app receives a redirect back via deep link. Native sign-in uses the OS-level account picker (Google's credential manager or Apple's ASAuthorizationController), which is faster since no browser opens.
The tradeoff: native feels more integrated but is only available for Google and Apple. Browser-based OAuth supports every provider Clerk offers, including GitHub, Microsoft, Discord, and more.
Configuring redirect URIs
For browser-based OAuth, the redirect URI must match your app's scheme. Use AuthSession.makeRedirectUri() to generate the correct URI. It reads the scheme from app.json automatically.
The scheme is already set from the project setup step: "scheme": "clerk-auth-demo". You also need to allowlist the redirect URL in the Clerk Dashboard for mobile SSO redirects.
Implementing browser-based OAuth with useSSO()
useSSO() is the Core 3 recommended hook for browser-based OAuth. It replaces the deprecated useOAuth().
Create a reusable OAuth screen. Start with a browser warm-up pattern for Android performance:
import { useEffect } from 'react'
import { Platform } from 'react-native'
import * as WebBrowser from 'expo-web-browser'
WebBrowser.maybeCompleteAuthSession()
export const useWarmUpBrowser = () => {
useEffect(() => {
if (Platform.OS !== 'android') return
void WebBrowser.warmUpAsync()
return () => {
void WebBrowser.coolDownAsync()
}
}, [])
}Then build the OAuth sign-in component:
import { useSSO } from '@clerk/expo'
import * as AuthSession from 'expo-auth-session'
import { TouchableOpacity, Text, Alert, View } from 'react-native'
export function BrowserOAuthButtons() {
useWarmUpBrowser()
const { startSSOFlow } = useSSO()
const handleOAuth = async (strategy: 'oauth_google' | 'oauth_github') => {
try {
const redirectUrl = AuthSession.makeRedirectUri()
const { createdSessionId, setActive } = await startSSOFlow({
strategy,
redirectUrl,
})
if (createdSessionId && setActive) {
await setActive({ session: createdSessionId })
}
} catch (err: any) {
Alert.alert('Error', `OAuth sign-in failed: ${err.message}`)
}
}
return (
<View>
<TouchableOpacity onPress={() => handleOAuth('oauth_google')}>
<Text>Sign in with Google (Browser)</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => handleOAuth('oauth_github')}>
<Text>Sign in with GitHub</Text>
</TouchableOpacity>
</View>
)
}The flow opens an in-app browser, the user authenticates with the provider, and the browser redirects back to your app via the custom scheme. If createdSessionId is returned, call setActive() to establish the session.
Adding GitHub as a second provider
GitHub uses the exact same useSSO() pattern with strategy: 'oauth_github'. Configure GitHub in the Clerk Dashboard as a social connection. Development instances use shared credentials, so you don't need a GitHub OAuth app for local testing.
For production, create a GitHub OAuth App, set the authorization callback URL from the Clerk Dashboard, and enter the client ID and secret. See the GitHub social connection guide for details.
Email and OTP authentication
How email OTP works with Clerk
Clerk sends a one-time passcode to the user's email. The user enters the code. Clerk verifies it server-side. No password storage, no reset flows, no forgotten password emails.
With native components (AuthView), email OTP is handled automatically. AuthView renders an email input and code verification screen for any email-based auth method enabled in the Dashboard.
Building a custom email OTP flow
For developers using the JavaScript-only approach (without AuthView), here's the custom flow using Core 3's SignInFuture API. Each method returns { error } instead of throwing, and signIn.status drives the flow between steps.
import { useSignIn } from '@clerk/expo'
import { useRouter, type Href } from 'expo-router'
import { useState } from 'react'
import {
View,
TextInput,
TouchableOpacity,
Text,
ActivityIndicator,
StyleSheet,
} from 'react-native'
export function EmailOTPSignIn() {
const { signIn, fetchStatus } = useSignIn()
const router = useRouter()
const [emailAddress, setEmailAddress] = useState('')
const [code, setCode] = useState('')
const [pendingVerification, setPendingVerification] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
if (fetchStatus === 'loading') return null
const handleSendCode = async () => {
setLoading(true)
setError('')
const { error: createError } = await signIn.create({ identifier: emailAddress })
if (createError) {
setError(createError.message || 'Failed to initiate sign-in')
setLoading(false)
return
}
const { error: sendError } = await signIn.emailCode.sendCode({ emailAddress })
if (sendError) {
setError(sendError.message || 'Failed to send code')
setLoading(false)
return
}
setPendingVerification(true)
setLoading(false)
}
const handleVerifyCode = async () => {
setLoading(true)
setError('')
const { error: verifyError } = await signIn.emailCode.verifyCode({ code })
if (verifyError) {
setError(verifyError.message || 'Invalid code')
setLoading(false)
return
}
if (signIn.status === 'complete') {
await signIn.finalize({
navigate: ({ session, decorateUrl }) => {
if (session?.currentTask) {
return
}
const url = decorateUrl('/')
router.push(url as Href)
},
})
}
setLoading(false)
}
return (
<View style={styles.container}>
{!pendingVerification ? (
<>
<TextInput
style={styles.input}
placeholder="Email address"
value={emailAddress}
onChangeText={setEmailAddress}
autoCapitalize="none"
keyboardType="email-address"
/>
<TouchableOpacity style={styles.button} onPress={handleSendCode} disabled={loading}>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Send Code</Text>
)}
</TouchableOpacity>
</>
) : (
<>
<TextInput
style={styles.input}
placeholder="Enter verification code"
value={code}
onChangeText={setCode}
keyboardType="number-pad"
/>
<TouchableOpacity style={styles.button} onPress={handleVerifyCode} disabled={loading}>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Verify</Text>
)}
</TouchableOpacity>
</>
)}
{error ? <Text style={styles.error}>{error}</Text> : null}
</View>
)
}
const styles = StyleSheet.create({
container: { padding: 24, gap: 16 },
input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 12, fontSize: 16 },
button: { backgroundColor: '#6C47FF', borderRadius: 8, padding: 14, alignItems: 'center' },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
error: { color: 'red', fontSize: 14 },
})When to use email OTP vs OAuth
OAuth is faster for returning users since it only takes one tap. Email OTP works universally because it doesn't require a third-party account, which makes it a good choice for enterprise users whose companies may restrict social logins. Most apps benefit from offering both. The native components approach with AuthView handles this automatically by rendering all enabled methods.
Protected routes with Expo Router
Authentication state with useAuth()
The useAuth() hook returns isLoaded, isSignedIn, userId, sessionId, and a getToken() method. Always check isLoaded before rendering to avoid a flash of wrong content during session restoration from secure storage.
const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
if (!isLoaded) {
return <LoadingSpinner />
}The getToken() method retrieves the current session token (a JSON Web Token) for API calls. Clerk's SDK automatically refreshes tokens in the background on a 50-second interval (tokens have a 60-second lifetime), so your app never blocks on a token refresh.
Setting up route groups
Expo Router uses file-based routing with route groups. Create an (auth) group for sign-in screens and an (app) group for authenticated content.
app/
_layout.tsx # Root layout with ClerkProvider + auth routing
(auth)/
\_layout.tsx
sign-in.tsx # AuthView screen
(app)/
_layout.tsx
index.tsx # Home screen with UserButton
profile.tsx # UserProfileView screenUpdate your root layout at app/_layout.tsx to handle auth-based routing:
import { ClerkProvider, useAuth } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
import { useRouter, useSegments, Slot } from 'expo-router'
import { useEffect } from 'react'
import { View, ActivityIndicator } from 'react-native'
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!
function AuthRouter() {
const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false })
const segments = useSegments()
const router = useRouter()
useEffect(() => {
if (!isLoaded) return
const inAuthGroup = segments[0] === '(auth)'
if (isSignedIn && inAuthGroup) {
router.replace('/(app)')
} else if (!isSignedIn && !inAuthGroup) {
router.replace('/(auth)/sign-in')
}
}, [isLoaded, isSignedIn, segments])
if (!isLoaded) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
)
}
return <Slot />
}
export default function RootLayout() {
return (
<ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
<AuthRouter />
</ClerkProvider>
)
}The Show component from @clerk/expo can also be used for conditional rendering within screens:
import { Show } from '@clerk/expo'
;<Stack>
<Show when="signed-in">
<Dashboard />
</Show>
<Show when="signed-out">
<SignInPrompt />
</Show>
</Stack>Expo Router also offers Stack.Protected as a newer alternative to manual redirect logic:
import { Stack } from 'expo-router'
;<Stack>
<Stack.Protected guard={isSignedIn}>
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Screen name="(auth)" />
</Stack>When guard is false, navigation to protected routes fails silently and users on a now-unguarded screen are redirected to the anchor route (typically the index screen). History entries for that screen are removed. Stack.Protected works with Stack, Tabs, and Drawer navigators and has been stable since SDK 53.
Handling deep links in authenticated routes
With the route group pattern, unauthenticated users who try to deep link into a protected route get redirected to sign-in. After signing in, the useEffect in the root layout redirects them to the (app) group. If you need to redirect back to the specific deep-linked route, store the intended path in local state before redirecting to sign-in.
Creating production builds
Registering your native app in Clerk Dashboard
Before building for production, register your app on the Clerk Dashboard's Native Applications page. This step is required for native components and native sign-in hooks to work in production.
- iOS: Enter your App ID Prefix (Team ID) and Bundle ID
- Android: Enter your namespace, package name, and SHA-256 certificate fingerprint
Allowlist your redirect URL: {bundleIdentifier}://callback. Clerk also requires a domain for production instances, even for mobile-only apps. Configure this in the production instance settings.
For full details, see the Expo production deployment guide.
Configuring eas.json for production
Create an eas.json file with build profiles for development, preview, and production:
{
"cli": {
"version": ">= 15.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"env": {
"EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY": "pk_test_your-dev-key"
}
},
"preview": {
"distribution": "internal",
"env": {
"EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY": "pk_test_your-dev-key"
}
},
"production": {
"distribution": "store",
"env": {
"EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY": "pk_live_your-prod-key"
}
}
},
"submit": {
"production": {}
}
}The key difference between profiles: development enables developmentClient for dev tools, preview is a release build for internal testing, and production targets app store distribution. Each profile can have its own EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to point at your development or production Clerk instance.
Building for iOS with EAS Build
Run the production build:
eas build --platform ios --profile productionEAS Build compiles native code on cloud macOS runners, signs the app with auto-managed credentials (distribution certificate and provisioning profile), and outputs an .ipa file. You don't need to manually create certificates or provisioning profiles in the Apple Developer portal. EAS generates and manages them for you. Run eas credentials to inspect or reset them. The Apple Developer Program ($99/year) is required. The free EAS tier includes 15 iOS builds per month.
Building for Android with EAS Build
eas build --platform android --profile productionThe default output is an .aab (Android App Bundle) for the Play Store. For direct installation, add "buildType": "apk" to the production profile. EAS manages the Android keystore automatically.
Environment-specific configuration
For more flexibility, switch from app.json to app.config.js with dynamic configuration:
const IS_DEV = process.env.APP_VARIANT === 'development'
export default {
name: IS_DEV ? 'Clerk Auth (Dev)' : 'Clerk Auth',
slug: 'clerk-auth-demo',
scheme: 'clerk-auth-demo',
ios: {
bundleIdentifier: IS_DEV ? 'com.yourapp.dev' : 'com.yourapp',
},
android: {
package: IS_DEV ? 'com.yourapp.dev' : 'com.yourapp',
},
plugins: ['@clerk/expo'],
}This lets you install development and production builds side by side on the same device with different bundle identifiers.
Distributing with TestFlight
Submitting to TestFlight
The fastest way to get a build into TestFlight is a single command:
npx testflightThis wraps eas build --platform ios --profile production --auto-submit. It builds the app, uploads the .ipa to App Store Connect, and enables TestFlight distribution for internal testers. Internal testers (up to 100 team members) get access immediately without App Store review. Builds expire after 90 days.
You can also run the steps separately:
eas build --platform ios --profile production --auto-submitOr build first and submit later:
eas build --platform ios --profile production
eas submit --platform iosAndroid distribution
For Android, share the .apk directly or use the Google Play internal testing track:
eas build --platform android --profile productionAdd "buildType": "apk" to the production profile in eas.json for direct sharing. For the Google Play internal track, use eas submit --platform android (requires a Google Play Console account).
Comparison: authentication approaches for Expo apps
All major auth providers require development builds for OAuth. Clerk's developer experience stands out: native SwiftUI/Jetpack Compose components, integrated native Google Sign-In without third-party packages, and a config plugin that handles native setup automatically.