A React Native SDK for displaying Bible content in Expo apps on iOS and Android. Toolbar, pickers, settings, sheets, and the auth button are native. Scripture renders in a WebView through the React Web SDK (@youversion/platform-react-ui) and Expo DOM. @expo/dom-webview and the Web SDK are required for those scripture surfaces.
- Features
- Requirements
- Installation
- Getting Started
- Usage
- Sample App
- Documentation
- Contributing
- Support
- License
- Scripture display: React Native components for Bible passages with
BibleTextViewandBibleCard - Bible Reader: a complete reading experience with
BibleReader, including a native toolbar on iOS/Android (chapter, version, Search, More) and built-in chapter, version, Search, and settings sheets - Verse of the Day: built-in
VerseOfTheDaycomponent - Sign in: optional PKCE OAuth via
YouVersionProvideranduseYVAuth(@youversion/platform-react-native-expo-core) - Highlights:
useHighlightsfor optimistic highlight writes backed by an instant local cache (@youversion/platform-react-native-expo-core); a highlight made offline keeps its paint, survives a relaunch, and lands on its own - Verse actions: selecting a verse in
BibleReaderopens a native bottom sheet with highlight colors, Copy, and Share - Theming:
light/dark/systemthemes, with per-component overrides anduseTokens()for the SDK's own color tokens - Native presentation: verse actions, footnotes, Search, and the chapter and version pickers open in native bottom sheets via
@gorhom/bottom-sheet
- Expo SDK 57 (React Native 0.86)
- A YouVersion Platform API key (register here)
Note: This SDK requires a dev build (not Expo Go) due to native module dependencies.
npx expo install @youversion/platform-react-native-expo-ui @youversion/platform-react-native-expo-coreThe UI package depends on core at runtime; install both so TypeScript resolves @youversion/platform-react-native-expo-core when you use auth APIs.
Install the required peer dependencies (Expo will pick versions compatible with your SDK):
npx expo install @gorhom/bottom-sheet @expo/dom-webview \
expo-application expo-clipboard expo-crypto expo-font expo-network expo-secure-store expo-web-browser \
react-dom \
react-native-gesture-handler react-native-mmkv \
react-native-nitro-modules react-native-reanimated@4.5.3 \
react-native-safe-area-context react-native-svg \
react-native-workletsExpo, React, and React Native are also peer dependencies, but they are expected to be provided by your Expo app.
For Expo 57, the install command pins react-native-reanimated to 4.5.3
(or use a compatible newer patch). Expo's recommended 4.5.1 contains a
settled-animation defect that can move native sheets offscreen after a delayed
React render. Version 4.5.3 includes the
upstream correction
and supports the example's React Native 0.86.3 and Worklets 0.10.1.
Rebuild the native app after installing. Do not let expo install --fix
downgrade it; keep the JS and native versions aligned.
See packages/ui/package.json and packages/core/package.json peerDependencies for the canonical lists.
- Get your app key: register your app with YouVersion Platform to acquire one.
- Wrap your app root with
YouVersionProvider:
import { YouVersionProvider } from '@youversion/platform-react-native-expo-ui'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
export default function RootLayout() {
const appKey = process.env.EXPO_PUBLIC_YOUVERSION_APP_KEY
if (!appKey) return null
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<YouVersionProvider appKey={appKey} theme="system">
{/* your app */}
</YouVersionProvider>
</GestureHandlerRootView>
)
}GestureHandlerRootView must wrap YouVersionProvider — the provider includes internal bottom-sheet support that depends on React Native Gesture Handler.
YouVersionProvider accepts theme="light" | "dark" | "system" and defaults to "system", which follows the device color scheme (falling back to "light" when the device scheme is unavailable). Components below can override the provider theme for that instance.
The same resolved scheme drives the SDK's own native chrome through design tokens. useTokens() returns the token set for the active scheme (getTokens("light" | "dark") outside a component), so an app can paint its own screens in the colors the SDK is using. Contributors: ADR 0021 covers the token architecture.
The provider also loads brand fonts (Inter, Untitled Serif, and Source Serif 4). There is no opt-out, no extra setup beyond appKey, and no public fonts-ready hook. Children wait for bundled Inter; Untitled Serif still loads in the background from the YouVersion Fonts API — the same license path as the web SDK. Allow api.youversion.com and cdn.youversion.com. If those hosts are blocked, or that request fails, native serif falls back to Source Serif 4.
Light and dark inside a WebView follow the resolved theme on that WebView's YouVersionProvider. Reader font size and family are Web SDK props — fontFamily crosses the native bridge as a quote-free token (ADR 0009).
Native and in-WebView SDK strings follow the device locale by default; see the localization guide for details and the locale override.
Display a verse range or single verse with BibleTextView:
import { BibleTextView } from '@youversion/platform-react-native-expo-ui'
function VerseScreen() {
return (
<BibleTextView
reference="JHN.3.16" // passage id: BOOK.CHAPTER.VERSE (or VERSE-VERSE for a range)
versionId={3034} // 3034 = Berean Standard Bible (BSB); find other IDs at platform.youversion.com
/>
)
}showVerseNumbers (default true) controls whether verse numbers render inline. Like BibleCard and VerseOfTheDay, a standalone BibleTextView sizes to its scripture by default (matchContents). Pass dom={{ matchContents: false }} to opt out and size with flex styles.
Display a Bible card with a verse and reader controls:
import { BibleCard } from '@youversion/platform-react-native-expo-ui'
// 3034 = Berean Standard Bible (BSB); find other IDs at platform.youversion.com
function CardScreen() {
return <BibleCard reference="JHN.3.16" defaultVersionId={3034} />
}See BibleCard in the component reference for its props.
Note: Scripture content is fetched from YouVersion servers; the underlying WebView caches responses for repeat reads.
BibleReader gives you a full Bible reading experience, ready to drop in as a tab or full screen:
import { BibleReader } from '@youversion/platform-react-native-expo-ui'
// 3034 = Berean Standard Bible (BSB); find other IDs at platform.youversion.com
function ReaderScreen() {
return <BibleReader defaultVersionId={3034} />
}BibleReader is stateful — it owns the current versionId and coordinates its built-in chapter and version picker sheets. It also paints the signed-in user's highlights on its own, provided your auth config requests the highlights permission — there is no prop to pass.
See BibleReader in the component reference for its native toolbar, Search, and built-in sheets.
BibleTextView, BibleCard, and VerseOfTheDay paint those same highlights on the passage they show, from the same cache. They do not create or remove highlights — tapping a verse on those surfaces still does nothing.
Create a BibleReaderNavigation object and pass it in. Use one object per Reader. You can call it before the reader mounts. A newer call replaces an older one; the reader consumes each request once.
import { useMemo } from 'react'
import { BibleReader, createBibleReaderNavigation } from '@youversion/platform-react-native-expo-ui'
function ReaderScreen() {
const navigation = useMemo(() => {
const readerNavigation = createBibleReaderNavigation()
readerNavigation.request({ versionId: 111, bookId: 'JHN', chapter: 3, verse: 16 })
return readerNavigation
}, [])
return <BibleReader navigation={navigation} defaultVersionId={3034} />
}request loads that version / book / chapter without focusing a verse. focusReference loads the chapter, scrolls to the verse, and dims the rest of the chapter.
Tapping a verse opens a native bottom sheet with the reference, Copy, and Share. When auth is configured on YouVersionProvider, the sheet also shows the highlight colors. It is the same surface the Swift and Kotlin SDKs present. It is on by default and needs no props.
The sheet has no backdrop, so a second verse tap reaches the passage and extends the selection. To dismiss the sheet, swipe down, deselect the verses, or act on the sheet.
The highlight colors write through the same highlights service as useHighlights. They need an auth config that requests the highlights permission (see Sign In). Without that config the colors are omitted — there is no sign-in to offer, and a tap would do nothing. With auth configured, the sheet asks a signed-out user, or one without the permission, for exactly what is missing. It then applies their color choice, with no reselecting of the verse.
Copy and Share fall back to expo-clipboard and React Native's Share. To handle either one yourself, pass onCopy or onShare:
<BibleReader
defaultVersionId={3034}
onCopy={async ({ text, reference }) => {
// text: verse text plus the reference line
}}
onShare={async ({ text }) => {
// your own share sheet
}}
/>On web, BibleReader keeps the React Web SDK's verse action popover, because native bottom sheets do not exist there. Its Copy and Share work. Its color swatches do not write.
A highlight tapped without service keeps its paint rather than disappearing, is persisted through a force-quit, and reaches the user's account on its own once service returns — including while the reader is on a different chapter, or not mounted at all. Nothing is required of you for that to work.
To surface it, pass onHighlightError. It fires only for writes worth telling the user about — a parked write and a transient failure — and stays silent for ok, noop, and the auth and invalid cases the reader already handles itself:
import { BibleReader, type HighlightWriteError } from '@youversion/platform-react-native-expo-ui'
function Reader() {
return (
<BibleReader
defaultVersionId={3034}
onHighlightError={(error: HighlightWriteError) => {
// { status: 'queued', verses } — saved on the device, will sync
// { status: 'error', reason: 'transient', verses, message } — the write did not stick
}}
/>
)
}queued reports the write just made, not the verse's history, so it repeats on every tap of a verse that is still parked. Show "saved offline" once by holding that in your own state.
BibleReader fetches highlights for the chapter on screen and refreshes when the app returns to the foreground. To pull in highlights made on another device or in the YouVersion app at some other moment — a screen refocus, a pull-to-refresh — call refreshHighlights() on the reader's ref:
import { useCallback, useRef } from 'react'
import { useFocusEffect } from 'expo-router'
import { BibleReader, type BibleReaderHandle } from '@youversion/platform-react-native-expo-ui'
function ReaderScreen() {
const reader = useRef<BibleReaderHandle>(null)
useFocusEffect(
useCallback(() => {
void reader.current?.refreshHighlights()
}, []),
)
return <BibleReader ref={reader} defaultVersionId={3034} />
}It is safe to call at any time: it de-dupes against a fetch already in flight, no-ops when signed out, and never clears what is already painted.
onVerseSelect reports every selection change, so you can react to one however you like — analytics, your own action UI, a custom share flow. It fires alongside the verse action sheet, not instead of it. clearSelectionSignal dismisses the current selection from native: increment it, and note its value at mount is the baseline, so mounting never clears.
const [clearSelectionSignal, setClearSelectionSignal] = useState(0)
<BibleReader
defaultVersionId={3034}
onVerseSelect={async (selection) => {
// selection.reference ("John 3:16"), .verses, .passageIds, .shareData
}}
clearSelectionSignal={clearSelectionSignal}
/>Clearing the selection also closes the verse action sheet. Clears arrive on onVerseSelect as well, as a selection with verses: []. Type a handler with BibleReaderVerseSelection / BibleReaderShareData, both re-exported from this package.
To present your own picker UI instead of the built-in sheets, pass onChapterPickerPress or onVersionPickerPress. The built-in sheet is suppressed and you receive the current selection. languageId is the Bible language tag (en, es), the same value the Web SDK picker uses. On native the trigger stays disabled until the version lookup settles, so the callback never fires mid-flight; if that lookup fails, languageId arrives as an empty string rather than a guess.
<BibleReader
defaultVersionId={3034}
onVersionPickerPress={({ versionId, languageId }) => {
// present your own version picker
}}
/>The standalone sheets are also exported (BibleChapterPickerSheet, BibleVersionPickerSheet, BibleReaderSettingsSheet) for advanced flows, along with BibleChapterPicker, which renders the native picker without a sheet. See the component reference for their props.
import { VerseOfTheDay } from '@youversion/platform-react-native-expo-ui'
// 3034 = Berean Standard Bible (BSB); find other IDs at platform.youversion.com
function VotdScreen() {
return <VerseOfTheDay versionId={3034} />
}Authentication is optional. Pass an auth config to YouVersionProvider to enable it. After the user signs in, the browser redirects back to your app at the redirectUri you configure below, so your app needs a route at that path to receive the redirect and finish sign-in. With Expo Router, that means a screen whose path matches the redirect (e.g. app/callback.tsx); the example app's implementation is a copyable reference: apps/example/app/callback.tsx.
The redirectUri is where the browser sends the user back after sign-in. Use youversionauth://callback, the callback URL the Swift and Kotlin SDKs use for the same purpose, and register that exact URI as the Callback URI for your app key in the YouVersion Platform console.
Two things have to line up, and they are the usual source of trouble:
-
redirectUrimust equal the Callback URI registered for your app key. An app key has exactly one. If they disagree, sign-in fails withinvalid_request: redirect_uri does not match registered callback URL. -
Android must be able to route it. Add the scheme to
app.jsonand rebuild the dev client (npx expo prebuild --clean— this is a native change):{ "expo": { "scheme": "youversionauth" } }iOS needs nothing extra. Because this scheme is shared by every app integrating the SDK, Android may show an app chooser if more than one is installed — the same tradeoff the Kotlin SDK's sample app makes.
import { YouVersionProvider } from '@youversion/platform-react-native-expo-ui'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
export default function RootLayout() {
const appKey = process.env.EXPO_PUBLIC_YOUVERSION_APP_KEY
const redirectUri = 'youversionauth://callback'
if (!appKey) return null
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<YouVersionProvider
appKey={appKey}
auth={{ redirectUri, scopes: ['profile', 'email'], permissions: ['highlights'] }}
>
{/* your app */}
</YouVersionProvider>
</GestureHandlerRootView>
)
}permissions lists YouVersion Platform permissions ('bibles', 'highlights', 'votd', 'demographics', 'bible_activity') to ask for on the consent screen — these are not OIDC scopes, so keep them out of scopes.
Requesting a permission is not the same as being granted it: the user can decline and sign-in still succeeds. Read back what they actually granted from useYVAuth() — hasPermission('highlights') for one check, or grantedPermissions for the whole list (null when nothing was requested or nothing is known yet, [] when the user declined). The grant is cached per user and survives a cold start.
A user who signed in before your app needed a permission — or who declined at the time — does not have to sign out to grant it. requestPermissions opens YouVersion's consent page and merges the result into the cached grant:
const { hasPermission, requestPermissions } = useYVAuth()
async function ensureHighlights() {
if (hasPermission('highlights')) return true
const outcome = await requestPermissions(['highlights'])
if (outcome.status === 'granted') return outcome.grantedPermissions.includes('highlights')
if (outcome.status === 'failure' && outcome.reason === 'not-permitted') {
// This app key is not enabled for the permission — a console setting, not a user choice.
}
return false
}It resolves rather than throwing: { status: 'granted', grantedPermissions }, { status: 'cancel' }, or { status: 'failure', reason, message } where reason is 'not-signed-in' | 'not-permitted' | 'user-changed' | 'in-progress' | 'transient'. A granted permission makes hasPermission true on the next render.
Only one request runs at a time. Calling it again for the same permissions while a consent page is open returns the in-flight request rather than opening a second one, so a double-tap on one button needs no guarding from you.
A second call for different permissions cannot share that answer — the open consent page never mentioned them. It resolves to { status: 'failure', reason: 'in-progress' }. Wait for the running request to settle before asking again; retrying straight away just hits the same branch.
Important
This flow returns to your redirectUri — the same callback URL sign-in uses. An app key has exactly one registered callback URL, and both browser round-trips come back through it. Nothing extra to register for data exchange beyond what sign-in already needs.
If your redirectUri disagrees with the callback URL registered for your app key, the consent page opens, the user consents, and the return never reaches the SDK — reported as { status: 'cancel' }, indistinguishable from a decline. Verify the two match before assuming users are declining.
For sign-in UI, drop in YouVersionAuthButton — it renders the branded Sign in with YouVersion button and handles sign-in/sign-out for you:
import { YouVersionAuthButton } from '@youversion/platform-react-native-expo-ui'
function ProfileScreen() {
return <YouVersionAuthButton />
}See YouVersionAuthButton in the component reference for its props.
Both SDK-owned sign-out surfaces — YouVersionAuthButton and BibleReader's More menu — ask before signing out, matching the Swift SDK. Sign-out is destructive: it drops the access token, the cached profile, the granted permissions, the cached highlights, and every highlight write still waiting to reach the server. When the queue holds unsent work, the confirmation escalates to "Save your highlights?". Every string is localized through the SDK's own catalog, and there is nothing to enable.
On web the confirmation is skipped and sign-out runs immediately, because React Native Web's Alert.alert is a no-op and a prompt there would leave the button doing nothing.
For your own sign-out UI, useSignOutGuard gives you the same confirmation:
import { useYVAuth } from '@youversion/platform-react-native-expo-core'
import { useSignOutGuard } from '@youversion/platform-react-native-expo-ui'
function SignOutButton() {
const auth = useYVAuth()
const signOut = useSignOutGuard(auth)
return <Button title="Sign out" onPress={() => void signOut?.()} />
}It returns undefined when auth is not configured, so it drops straight into an optional handler prop. Confirming calls signOut() and nothing else — the SDK clears the queue and the caches for you. Cancelling leaves the user signed in with the queue intact.
useYVAuth().signOut() is unguarded and signs out immediately, which is what your own confirmation flow wants. To decide between the two confirmation copies yourself, hasQueuedHighlightWrites(userId) from the core package answers whether anything is still waiting.
To build custom UI instead, use the useYVAuth hook:
import { useYVAuth } from '@youversion/platform-react-native-expo-core'
import { Button } from 'react-native'
function SignInButton() {
const { isAuthenticated, isLoading, signIn, signOut } = useYVAuth()
if (isLoading) return null
if (isAuthenticated) {
return <Button title="Sign out" onPress={() => signOut()} />
}
return <Button title="Sign in with YouVersion" onPress={() => signIn()} />
}Calling useYVAuth() requires that the surrounding YouVersionProvider received an auth config — without it the hook throws. Tokens are stored in expo-secure-store; profile metadata is cached in MMKV. See apps/example for a working callback route and Profile tab.
Explore the apps/example directory for a sample Expo Router app demonstrating:
- Bible reader integration
- Verse actions, including
onCopy/onShareoverrides - Bible card and Scripture display
- Verse of the Day
- PKCE sign-in, OAuth callback handling, and the Profile tab
- Provider and native dependency setup
Set EXPO_PUBLIC_YOUVERSION_APP_KEY in your environment or an .env file before starting the example app.
To run it:
git clone https://github.com/youversion/platform-sdk-reactnative-expo.git
cd platform-sdk-reactnative-expo
pnpm install
# Build the dev client (first time)
cd apps/example
pnpm build:ios # or: pnpm build:android
# Subsequent runs
pnpm exec expo start --dev-clientSee the Contributing Guide for additional local development setup.
- React Native (Expo) SDK Guide: quick start and integration guide for this SDK
- API Documentation: REST API reference for advanced integration patterns and endpoints
- Sample Code: working example app and provider setup
Note
We are not yet accepting pull requests from external contributors. In the meantime, we welcome you to use the SDK, report bugs via GitHub Issues, and share feedback.
For internal development, see the Contributing Guide.
- Issues: GitHub Issues
- Platform Support: YouVersion Platform
This SDK is licensed under the Apache License 2.0. See LICENSE for details.
Made with ❤️ by YouVersion
