App Gates
Customize onboarding, consent, version, auth, biometric, and rating flows.
The Expo template's launch gates are sibling components around the router
<Stack>, not a nested native wrapper chain. That matters when you customize
which gates redirect and which gates overlay the current screen.
Real topology
app/_layout.tsx mounts providers first, then renders the gates in this order:
RootErrorBoundary
-> providers
-> OnboardingGate
-> AuthGate
-> VersionGate
-> <Stack />
-> ConsentGate
-> BiometricGate
-> PortalHost| Gate | Behavior | Notes |
|---|---|---|
RootErrorBoundary | Error boundary | Wraps the full app and reports to Sentry |
OnboardingGate | Redirect | Sends first-run users to /onboarding except onboarding, referral, and legal routes |
AuthGate | Redirect cleanup | Guest-first; it only dismisses auth screens after sign-in |
VersionGate | Overlay modal/dialog | Blocks on forced updates and shows dismissible soft updates |
ConsentGate | Overlay modal | Requires the current legal version, except while reading legal screens |
BiometricGate | Full-screen overlay | Locks signed-in users who enabled biometric app lock |
Onboarding
lib/onboarding.tsx stores completion in its own cache key:
growth.onboarding.v1app/onboarding.tsx renders four slides:
launch -> protect -> grow -> permissionsFinishing onboarding tracks onboarding completed, records a rating trigger,
persists { completed: true, completedAt }, and replaces the route with Home.
Auth gate
The app is guest-first. AuthGate does not wall the app at launch. Protected
actions use SignInGate inside the relevant screen or component.
When a signed-in user is still inside (auth) and not on password recovery,
AuthGate closes the auth flow:
canGoBack() -> router.back()
otherwise -> router.replace("/")This makes login behave like a modal raised by the current task, while still allowing cold-start login routes.
Version gate
lib/app-update-config.ts reads env config:
EXPO_PUBLIC_MIN_APP_VERSION=
EXPO_PUBLIC_LATEST_APP_VERSION=
EXPO_PUBLIC_APP_UPDATE_URL=
EXPO_PUBLIC_APP_UPDATE_MESSAGE=
EXPO_PUBLIC_APP_UPDATE_CONFIG_URL=If APP_UPDATE_CONFIG_URL is set, the app fetches remote JSON and merges it
over the env values. The TanStack Query cache uses a 10-minute staleTime.
| State | Rule |
|---|---|
| Force update | CURRENT_APP_VERSION < minVersion; shows a non-dismissable modal |
| Soft update | CURRENT_APP_VERSION < latestVersion; shows a dismissible dialog |
| No gate | No config, equal/newer current version, or failed open URL |
To test locally, set EXPO_PUBLIC_MIN_APP_VERSION=2.0.0 while the app version
in app.config.ts is 1.0.0, restart Metro, and open the app.
Consent gate
LEGAL_VERSION is defined in lib/legal.ts:
export const LEGAL_VERSION = "2026-04-30";ConsentProvider persists the accepted legal version in:
legal.consent.v1When the version changes, existing users are prompted again. Legal documents
live under app/legal/; external legal URLs come from
EXPO_PUBLIC_TERMS_URL, EXPO_PUBLIC_PRIVACY_URL, and EXPO_PUBLIC_EULA_URL.
This is a legal-consent gate, not an analytics-consent gate. In the current
provider order, AnalyticsProvider mounts before ConsentProvider, so
PostHog can initialize and screen tracking can run as soon as a PostHog key is
configured.
If your policy requires analytics only after consent, change the implementation:
read consent before mounting PostHog or make isAnalyticsEnabled() depend on a
stored analytics-consent value.
Biometric lock
lib/biometric.tsx uses expo-local-authentication. It checks hardware,
enrollment, and supported types, then stores the user's opt-in at:
cache.biometric.enabled.v1When a signed-in user enables biometric lock, the app locks immediately and
locks again after returning from background if it was away for more than 30
seconds. BiometricGate renders outside <Stack />, so it covers every active
screen.
This is Native-only and requires hardware or enrolled device authentication.
Rating prompt
Rating is not a gate. lib/rating.ts increments an action counter when
onboarding completes or a referral invite is shared. A review prompt is allowed
only when:
actionCount >= 3
not prompted for this app version
at least 90 days since the last prompt
expo-store-review has an available actionlib/store-review.web.ts always returns false, so web never prompts.
Customize
- Remove a gate by removing its provider state and component from
app/_layout.tsx. - Reorder carefully: redirect gates belong before
<Stack />; overlays that must cover the current route belong after<Stack />. - Reset persisted flags during QA by clearing the app's local storage/cache or by calling the provider reset helpers from a temporary debug screen.
- Keep App Review notes aligned with the code: account deletion is in Account & Security, legal consent is versioned, and ATT is separate from this consent gate.
