diff --git a/.changeset/hosted-navigation-native-views.md b/.changeset/hosted-navigation-native-views.md
new file mode 100644
index 00000000000..12658382e7e
--- /dev/null
+++ b/.changeset/hosted-navigation-native-views.md
@@ -0,0 +1,16 @@
+---
+'@clerk/expo': minor
+---
+
+Support pushing the native `UserProfileView` and `AuthView` onto your app's own navigation stack.
+
+New optional `onHostBack` prop shows a back button on the component's root screen and calls you when it is tapped. The component keeps its own navigation chrome, so screen titles, back buttons, swipe-back, and transitions inside the component stay native — hide your route's header and pop your route from the callback:
+
+```tsx
+
+ router.back()} />
+```
+
+The component never leaves the route on its own, so react to auth state for flow completion — either swap the content in place or pop the route.
+
+Existing usage is unaffected: the prop is optional, and the components render exactly as before without it. Requires the corresponding clerk-ios and clerk-android SDK releases.
diff --git a/integration/templates/expo-native/App.tsx b/integration/templates/expo-native/App.tsx
index 3d7bdf3efa4..967814e9f68 100644
--- a/integration/templates/expo-native/App.tsx
+++ b/integration/templates/expo-native/App.tsx
@@ -1,5 +1,5 @@
import { ClerkProvider, useAuth, useUser } from '@clerk/expo';
-import { AuthView, UserButton } from '@clerk/expo/native';
+import { AuthView, UserButton, UserProfileView } from '@clerk/expo/native';
import { tokenCache } from '@clerk/expo/token-cache';
import { useState } from 'react';
import { Button, Modal, StyleSheet, Text, View } from 'react-native';
@@ -18,6 +18,7 @@ function NativeBuildFixture() {
const { isLoaded, isSignedIn, signOut } = useAuth({ treatPendingAsSignedOut: false });
const { user } = useUser();
const [isAuthOpen, setIsAuthOpen] = useState(false);
+ const [isProfileOpen, setIsProfileOpen] = useState(false);
const [e2eStatus, setE2eStatus] = useState(null);
return (
@@ -38,6 +39,13 @@ function NativeBuildFixture() {
{!isSignedIn && }
{isSignedIn && }
{e2eStatus && {e2eStatus} }
+ {isSignedIn && (
+ setIsProfileOpen(true)}
+ />
+ )}
{isSignedIn && (
)}
+ {isProfileOpen && (
+
+ setIsProfileOpen(false)}
+ />
+
+ )}
+
{
AddChildView { parent, child, _ ->
@@ -201,10 +216,13 @@ class ClerkAuthViewModule : Module() {
view.logoMaxHeight = logoMaxHeight
}
+ Prop("hostBackButton") { view: ClerkAuthNativeView, hostBackButton: Boolean ->
+ view.hostBackButton = hostBackButton
+ }
+
OnViewDidUpdateProps { view: ClerkAuthNativeView ->
view.setupView()
}
-
}
}
}
diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt
index cbb83c72df3..51826181aff 100644
--- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt
+++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt
@@ -1,13 +1,15 @@
+@file:OptIn(FrameworkIntegrationApi::class)
+
package expo.modules.clerk
import android.content.Context
import android.util.Log
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import com.clerk.api.Clerk
+import com.clerk.api.FrameworkIntegrationApi
+import com.clerk.ui.navigation.ClerkHostBackActionProvider
import com.clerk.ui.userprofile.UserProfileView
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
@@ -25,7 +27,9 @@ private fun debugLog(tag: String, message: String) {
class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : ClerkComposeNativeViewHost(context, appContext) {
// clerk-android UserProfileView dismissibility is controlled by its onDismiss callback.
var isDismissible: Boolean = true
+ var hostBackButton: Boolean = false
private val onProfileEvent by EventDispatcher()
+ private val onHostBack by EventDispatcher()
private val viewModelStoreOwner = object : ViewModelStoreOwner {
private val store = ViewModelStore()
@@ -40,15 +44,24 @@ class ClerkUserProfileNativeView(context: Context, appContext: AppContext) : Cle
@Composable
override fun Content() {
- debugLog(TAG, "setupView - isDismissible: $isDismissible")
+ debugLog(TAG, "setupView - isDismissible: $isDismissible, hostBackButton: $hostBackButton")
+
+ if (hostBackButton) {
+ ClerkHostBackActionProvider(onHostBack = { onHostBack(mapOf()) }) { ProfileView() }
+ } else {
+ ProfileView()
+ }
+ }
+ @Composable
+ private fun ProfileView() {
UserProfileView(
clerkTheme = Clerk.customTheme,
isDismissible = isDismissible,
onDismiss = {
debugLog(TAG, "Profile dismissed")
sendEvent("dismissed")
- }
+ },
)
}
@@ -62,12 +75,16 @@ class ClerkUserProfileViewModule : Module() {
Name("ClerkUserProfileView")
View(ClerkUserProfileNativeView::class) {
- Events("onProfileEvent")
+ Events("onProfileEvent", "onHostBack")
Prop("isDismissible") { view: ClerkUserProfileNativeView, isDismissible: Boolean ->
view.isDismissible = isDismissible
}
+ Prop("hostBackButton") { view: ClerkUserProfileNativeView, hostBackButton: Boolean ->
+ view.hostBackButton = hostBackButton
+ }
+
OnViewDidUpdateProps { view: ClerkUserProfileNativeView ->
view.setupView()
}
diff --git a/packages/expo/ios/ClerkAuthNativeView.swift b/packages/expo/ios/ClerkAuthNativeView.swift
index 6d3ac2f97cb..e76a8be1b1c 100644
--- a/packages/expo/ios/ClerkAuthNativeView.swift
+++ b/packages/expo/ios/ClerkAuthNativeView.swift
@@ -7,9 +7,11 @@ public class ClerkAuthNativeView: ClerkNativeViewHost {
private var currentLogoMaxHeight: CGFloat?
private let logoState = ClerkInlineAuthLogoState()
private var logoBoundsObservation: NSKeyValueObservation?
+ private var currentHostBackButton: Bool = false
private var didSendDismiss = false
let onAuthEvent = EventDispatcher()
+ let onHostBack = EventDispatcher()
func setMode(_ mode: String?) {
let newMode = mode ?? "signInOrUp"
@@ -31,6 +33,13 @@ public class ClerkAuthNativeView: ClerkNativeViewHost {
setNeedsHostedViewUpdate()
}
+ func setHostBackButton(_ hostBackButton: Bool?) {
+ let newHostBackButton = hostBackButton ?? false
+ guard newHostBackButton != currentHostBackButton else { return }
+ currentHostBackButton = newHostBackButton
+ setNeedsHostedViewUpdate()
+ }
+
private func sendAuthEvent(type: ClerkNativeViewEvent) {
onAuthEvent(["type": type.rawValue])
}
@@ -98,11 +107,16 @@ public class ClerkAuthNativeView: ClerkNativeViewHost {
}
override func makeHostedController() -> UIViewController? {
+ let hostBackAction: (() -> Void)? = currentHostBackButton
+ ? { [weak self] in self?.onHostBack([:]) }
+ : nil
+
return ClerkNativeBridge.shared.makeAuthViewController(
mode: currentMode,
dismissible: currentDismissible,
logoState: logoState,
logoMaxHeight: currentLogoMaxHeight,
+ hostBackAction: hostBackAction,
onEvent: { [weak self] event, _ in
if event == .dismissed {
self?.sendDismissIfNeeded()
@@ -117,7 +131,7 @@ public class ClerkAuthViewModule: Module {
Name("ClerkAuthView")
View(ClerkAuthNativeView.self) {
- Events("onAuthEvent")
+ Events("onAuthEvent", "onHostBack")
Prop("mode") { (view: ClerkAuthNativeView, mode: String?) in
view.setMode(mode)
@@ -130,6 +144,11 @@ public class ClerkAuthViewModule: Module {
Prop("logoMaxHeight") { (view: ClerkAuthNativeView, logoMaxHeight: CGFloat?) in
view.setLogoMaxHeight(logoMaxHeight)
}
+
+ Prop("hostBackButton") { (view: ClerkAuthNativeView, hostBackButton: Bool?) in
+ view.setHostBackButton(hostBackButton)
+ }
+
}
}
}
diff --git a/packages/expo/ios/ClerkNativeBridge.swift b/packages/expo/ios/ClerkNativeBridge.swift
index 1d2cf07fe3f..fccd156a501 100644
--- a/packages/expo/ios/ClerkNativeBridge.swift
+++ b/packages/expo/ios/ClerkNativeBridge.swift
@@ -4,7 +4,7 @@ import UIKit
import SwiftUI
import Observation
@_spi(FrameworkIntegration) import ClerkKit
-import ClerkKitUI
+@_spi(FrameworkIntegration) import ClerkKitUI
/// Events emitted by the native view wrappers to their React Native host views.
public enum ClerkNativeViewEvent: String {
@@ -271,6 +271,7 @@ final class ClerkNativeBridge {
dismissible: Bool,
logoState: ClerkInlineAuthLogoState,
logoMaxHeight: CGFloat?,
+ hostBackAction: (() -> Void)? = nil,
onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void
) -> UIViewController? {
guard Self.clerkConfigured else { return nil }
@@ -279,6 +280,7 @@ final class ClerkNativeBridge {
rootView: ClerkInlineAuthWrapperView(
mode: Self.authMode(from: mode),
dismissible: dismissible,
+ hostBackAction: hostBackAction.map(ClerkHostBackAction.init),
lightTheme: lightTheme,
darkTheme: darkTheme,
logoState: logoState,
@@ -290,6 +292,7 @@ final class ClerkNativeBridge {
func makeUserProfileViewController(
dismissible: Bool,
+ hostBackAction: (() -> Void)? = nil,
onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void
) -> UIViewController? {
guard Self.clerkConfigured else { return nil }
@@ -297,6 +300,7 @@ final class ClerkNativeBridge {
return makeHostingController(
rootView: ClerkInlineProfileWrapperView(
dismissible: dismissible,
+ hostBackAction: hostBackAction.map(ClerkHostBackAction.init),
lightTheme: lightTheme,
darkTheme: darkTheme
),
@@ -531,6 +535,7 @@ struct ClerkInlineUserButtonWrapperView: View {
struct ClerkInlineAuthWrapperView: View {
let mode: AuthView.Mode
let dismissible: Bool
+ let hostBackAction: ClerkHostBackAction?
let lightTheme: ClerkTheme?
let darkTheme: ClerkTheme?
let logoState: ClerkInlineAuthLogoState
@@ -541,6 +546,7 @@ struct ClerkInlineAuthWrapperView: View {
@ViewBuilder private var themedAuthView: some View {
let view = AuthView(mode: mode, isDismissible: dismissible)
.environment(Clerk.shared)
+ .environment(\.clerkHostBackAction, hostBackAction)
let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme
let themedView = Group {
if let theme {
@@ -635,6 +641,7 @@ private final class ClerkNativeHostingController: UIHostingContro
struct ClerkInlineProfileWrapperView: View {
let dismissible: Bool
+ let hostBackAction: ClerkHostBackAction?
let lightTheme: ClerkTheme?
let darkTheme: ClerkTheme?
@@ -643,6 +650,7 @@ struct ClerkInlineProfileWrapperView: View {
var body: some View {
let view = UserProfileView(isDismissible: dismissible)
.environment(Clerk.shared)
+ .environment(\.clerkHostBackAction, hostBackAction)
let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme
let themedView = Group {
if let theme {
diff --git a/packages/expo/ios/ClerkUserProfileNativeView.swift b/packages/expo/ios/ClerkUserProfileNativeView.swift
index 78d8e298159..12d6248b1dc 100644
--- a/packages/expo/ios/ClerkUserProfileNativeView.swift
+++ b/packages/expo/ios/ClerkUserProfileNativeView.swift
@@ -3,9 +3,11 @@ import UIKit
public class ClerkUserProfileNativeView: ClerkNativeViewHost {
private var currentDismissible: Bool = true
+ private var currentHostBackButton: Bool = false
private var didSendDismiss = false
let onProfileEvent = EventDispatcher()
+ let onHostBack = EventDispatcher()
func setDismissible(_ isDismissible: Bool?) {
let newDismissible = isDismissible ?? true
@@ -14,6 +16,13 @@ public class ClerkUserProfileNativeView: ClerkNativeViewHost {
setNeedsHostedViewUpdate()
}
+ func setHostBackButton(_ hostBackButton: Bool?) {
+ let newHostBackButton = hostBackButton ?? false
+ guard newHostBackButton != currentHostBackButton else { return }
+ currentHostBackButton = newHostBackButton
+ setNeedsHostedViewUpdate()
+ }
+
private func sendProfileEvent(type: ClerkNativeViewEvent) {
onProfileEvent(["type": type.rawValue])
}
@@ -34,8 +43,13 @@ public class ClerkUserProfileNativeView: ClerkNativeViewHost {
}
override func makeHostedController() -> UIViewController? {
+ let hostBackAction: (() -> Void)? = currentHostBackButton
+ ? { [weak self] in self?.onHostBack([:]) }
+ : nil
+
return ClerkNativeBridge.shared.makeUserProfileViewController(
dismissible: currentDismissible,
+ hostBackAction: hostBackAction,
onEvent: { [weak self] event, _ in
if event == .dismissed {
self?.sendDismissIfNeeded()
@@ -50,11 +64,15 @@ public class ClerkUserProfileViewModule: Module {
Name("ClerkUserProfileView")
View(ClerkUserProfileNativeView.self) {
- Events("onProfileEvent")
+ Events("onProfileEvent", "onHostBack")
Prop("isDismissible") { (view: ClerkUserProfileNativeView, isDismissible: Bool?) in
view.setDismissible(isDismissible)
}
+
+ Prop("hostBackButton") { (view: ClerkUserProfileNativeView, hostBackButton: Bool?) in
+ view.setHostBackButton(hostBackButton)
+ }
}
}
}
diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo/src/native/AuthView.tsx
index 3067b9daa7a..3f4b51ee346 100644
--- a/packages/expo/src/native/AuthView.tsx
+++ b/packages/expo/src/native/AuthView.tsx
@@ -1,4 +1,4 @@
-import { type ReactElement, useCallback } from 'react';
+import { useCallback } from 'react';
import type { NativeSyntheticEvent } from 'react-native';
import { Text, View } from 'react-native';
@@ -19,6 +19,9 @@ type AuthNativeEvent = NativeSyntheticEvent>;
* Use `useAuth()`, `useUser()`, or `useSession()` to react to authentication
* state changes.
*
+ * To push the auth flow onto your own navigation stack, hide the route's header and
+ * pass `onHostBack` so Clerk's own chrome takes over.
+ *
* @example
* ```tsx
* import { AuthView } from '@clerk/expo/native';
@@ -43,7 +46,8 @@ export function AuthView({
isDismissible = true,
logoMaxHeight,
onDismiss,
-}: AuthViewProps): ReactElement {
+ onHostBack,
+}: AuthViewProps) {
const handleAuthEvent = useCallback(
(event: AuthNativeEvent) => {
if (event.nativeEvent.type === 'dismissed') {
@@ -71,7 +75,9 @@ export function AuthView({
mode={mode}
isDismissible={isDismissible}
logoMaxHeight={logoMaxHeight}
+ hostBackButton={!!onHostBack}
onAuthEvent={handleAuthEvent}
+ onHostBack={onHostBack ? () => onHostBack() : undefined}
>
{logo ? (
+ * router.back()} />
+ * ```
+ *
+ * The component never leaves the route on its own, so react to auth state
+ * for flow completion — swap the content in place, or pop the route.
+ */
+ onHostBack?: () => void;
+}
diff --git a/packages/expo/src/native/UserProfileView.tsx b/packages/expo/src/native/UserProfileView.tsx
index 1263569d5c2..e1eba05cf6d 100644
--- a/packages/expo/src/native/UserProfileView.tsx
+++ b/packages/expo/src/native/UserProfileView.tsx
@@ -4,11 +4,12 @@ import { StyleSheet, Text, View } from 'react-native';
import NativeClerkUserProfileView from '../specs/NativeClerkUserProfileView';
import { isNativeSupported } from '../utils/native-module';
+import type { EmbeddedNavigationProps } from './EmbeddedNavigation.types';
/**
* Props for the UserProfileView component.
*/
-export interface UserProfileViewProps {
+export interface UserProfileViewProps extends EmbeddedNavigationProps {
/**
* Whether the inline profile view shows a dismiss button.
*
@@ -39,6 +40,9 @@ export interface UserProfileViewProps {
*
* To present the profile, render it inside your own `Modal`, sheet, or route.
*
+ * To push the profile onto your own navigation stack, hide the route's header and
+ * pass `onHostBack` so Clerk's own chrome takes over.
+ *
* Sign-out is detected automatically and synced with the JS SDK. Use `useAuth()` in a
* `useEffect` to react to sign-out.
*
@@ -60,7 +64,7 @@ export interface UserProfileViewProps {
*
* @see {@link https://clerk.com/docs/components/user/user-profile} Clerk UserProfile Documentation
*/
-export function UserProfileView({ isDismissible = true, style, onDismiss }: UserProfileViewProps) {
+export function UserProfileView({ isDismissible = true, style, onDismiss, onHostBack }: UserProfileViewProps) {
const handleProfileEvent = useCallback(
(event: { nativeEvent: { type: string } }) => {
if (event.nativeEvent.type === 'dismissed') {
@@ -86,7 +90,9 @@ export function UserProfileView({ isDismissible = true, style, onDismiss }: User
onHostBack() : undefined}
/>
);
}
diff --git a/packages/expo/src/native/__tests__/UserProfileView.test.tsx b/packages/expo/src/native/__tests__/UserProfileView.test.tsx
new file mode 100644
index 00000000000..099e3308fbc
--- /dev/null
+++ b/packages/expo/src/native/__tests__/UserProfileView.test.tsx
@@ -0,0 +1,70 @@
+import { render } from '@testing-library/react';
+import React from 'react';
+import { describe, expect, test, vi } from 'vitest';
+
+import { UserProfileView } from '../UserProfileView';
+
+const mocks = vi.hoisted(() => {
+ return {
+ nativeProps: vi.fn(),
+ };
+});
+
+vi.mock('../../specs/NativeClerkUserProfileView', () => {
+ return {
+ default: (props: Record) => {
+ mocks.nativeProps(props);
+ return null;
+ },
+ };
+});
+
+vi.mock('../../utils/native-module', () => {
+ return {
+ isNativeSupported: true,
+ };
+});
+
+vi.mock('react-native', () => {
+ return {
+ Text: ({ children }: { children?: React.ReactNode }) => React.createElement('span', null, children),
+ View: ({ children }: { children?: React.ReactNode }) => React.createElement('div', null, children),
+ StyleSheet: { create: (styles: T) => styles },
+ };
+});
+
+function lastNativeProps() {
+ return mocks.nativeProps.mock.calls.at(-1)?.[0];
+}
+
+describe('UserProfileView', () => {
+ test('calls onDismiss when the native profile view emits dismissed', () => {
+ const onDismiss = vi.fn();
+
+ render( );
+
+ lastNativeProps().onProfileEvent({ nativeEvent: { type: 'dismissed' } });
+
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+
+ test('shows the root back button and calls onHostBack when it is tapped', () => {
+ const onHostBack = vi.fn();
+
+ render( );
+
+ const props = lastNativeProps();
+ expect(props.hostBackButton).toBe(true);
+ props.onHostBack();
+
+ expect(onHostBack).toHaveBeenCalledTimes(1);
+ });
+
+ test('does not show a root back button without onHostBack', () => {
+ render( );
+
+ const props = lastNativeProps();
+ expect(props.hostBackButton).toBe(false);
+ expect(props.onHostBack).toBeUndefined();
+ });
+});
diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts
index b59a8eeb106..d892fb9a851 100644
--- a/packages/expo/src/native/index.ts
+++ b/packages/expo/src/native/index.ts
@@ -30,6 +30,7 @@
export { AuthView } from './AuthView';
export type { AuthViewProps, AuthViewMode } from './AuthView.types';
+export type { EmbeddedNavigationProps } from './EmbeddedNavigation.types';
export { UserButton } from './UserButton';
export { UserProfileView } from './UserProfileView';
export type { UserProfileViewProps } from './UserProfileView';
diff --git a/packages/expo/src/specs/NativeClerkAuthView.android.ts b/packages/expo/src/specs/NativeClerkAuthView.android.ts
index 3d1ea374baa..43c0ec2a435 100644
--- a/packages/expo/src/specs/NativeClerkAuthView.android.ts
+++ b/packages/expo/src/specs/NativeClerkAuthView.android.ts
@@ -7,7 +7,9 @@ interface NativeProps extends ViewProps {
mode?: string;
isDismissible?: boolean;
logoMaxHeight?: number;
+ hostBackButton?: boolean;
onAuthEvent?: (event: NativeSyntheticEvent) => void;
+ onHostBack?: (event: NativeSyntheticEvent) => void;
}
export default requireNativeView('ClerkAuthView');
diff --git a/packages/expo/src/specs/NativeClerkAuthView.ts b/packages/expo/src/specs/NativeClerkAuthView.ts
index 32dc196c4b2..9a0de17dc62 100644
--- a/packages/expo/src/specs/NativeClerkAuthView.ts
+++ b/packages/expo/src/specs/NativeClerkAuthView.ts
@@ -8,7 +8,9 @@ interface NativeProps extends ViewProps {
mode?: string;
isDismissible?: boolean;
logoMaxHeight?: number;
+ hostBackButton?: boolean;
onAuthEvent?: (event: NativeSyntheticEvent) => void;
+ onHostBack?: (event: NativeSyntheticEvent) => void;
}
const NativeClerkAuthView =
diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.android.ts b/packages/expo/src/specs/NativeClerkUserProfileView.android.ts
index 7ac253fb341..80192f2219f 100644
--- a/packages/expo/src/specs/NativeClerkUserProfileView.android.ts
+++ b/packages/expo/src/specs/NativeClerkUserProfileView.android.ts
@@ -5,7 +5,9 @@ type ProfileEvent = Readonly<{ type: string }>;
interface NativeProps extends ViewProps {
isDismissible?: boolean;
+ hostBackButton?: boolean;
onProfileEvent?: (event: NativeSyntheticEvent) => void;
+ onHostBack?: (event: NativeSyntheticEvent) => void;
}
export default requireNativeView('ClerkUserProfileView');
diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.ts b/packages/expo/src/specs/NativeClerkUserProfileView.ts
index 819efcef803..1e8494d9b75 100644
--- a/packages/expo/src/specs/NativeClerkUserProfileView.ts
+++ b/packages/expo/src/specs/NativeClerkUserProfileView.ts
@@ -6,7 +6,9 @@ type ProfileEvent = Readonly<{ type: string }>;
interface NativeProps extends ViewProps {
isDismissible?: boolean;
+ hostBackButton?: boolean;
onProfileEvent?: (event: NativeSyntheticEvent) => void;
+ onHostBack?: (event: NativeSyntheticEvent) => void;
}
const NativeClerkUserProfileView =