Sooner or later JavaScript is not enough. A payment SDK, Bluetooth, a camera with real controls, a vendor's analytics library — all of it is Swift and Kotlin, and a native module is the bridge to it.
Why Expo Go stops working
Expo Go is a pre-built app. It contains a fixed set of native modules that Expo compiled into it, and it cannot load anything else — there is no mechanism for shipping new native code to an already built binary.
A development build is your own binary, containing exactly the native code your project declares:
npx expo run:iosThat is the whole difference, and it is where "Expo cannot use native modules" comes from. The statement was true of Expo Go and has never been true of a development build.
prebuild, and treating native projects as output
npx expo prebuild
npx expo prebuild --cleanprebuild generates ios/ and android/ from
app.config.ts and your dependencies. run:ios calls it for you.
The mental shift worth making: those folders are build output, not source. Do
not edit them and do not commit them — the demo app gitignores both. Any change you make by hand is
silently discarded the next time --clean runs, which is a bad way to lose an
afternoon.
Everything you would have edited there is expressed in the config instead. That is what makes an Expo upgrade a version bump rather than a merge conflict in files nobody understands.
Config plugins
A config plugin is a function that edits the generated native projects — adding an entitlement, a permission string, a manifest entry. Libraries ship their own, and you list them:
plugins: [
'expo-router',
'expo-secure-store',
'expo-font',
'expo-web-browser',
[
'expo-splash-screen',
{
image: './assets/splash-icon.png',
resizeMode: 'contain',
backgroundColor: PIZZA_CREAM,
},
],A bare string is a plugin with no options; an array is a plugin with options. Adding one and
re-running prebuild is the entire installation procedure for most native libraries.
⚠️ Plugins can have required options, and the failure is not graceful. Adding Stripe
without merchantIdentifier — which it demands even with Apple Pay switched off — makes
expo install crash with Cannot read properties of undefined (reading
'merchantIdentifier'), which does not obviously mean "you forgot a config option".
Check the peers
npx expo-doctorWorth running after adding anything native. A missing native peer dependency typically builds
fine and then crashes at launch — expo-doctor catches it before the build does not.
Quarantining a native dependency
This is the architectural part, and it is the most reusable idea in the lesson.
A native module cannot run everywhere. Stripe's SDK has no web build at all, so merely importing it breaks the web bundle — and it makes anything that imports it awkward to test, because there is nothing to import in Node either.
So it lives in exactly one folder, behind an interface:
export type PaymentOutcome =
| { status: 'succeeded' }
| { status: 'cancelled' }
| { status: 'failed'; message: string };The contract is written in terms of the domain — an order was paid, or the customer changed their mind, or it failed — not in terms of Stripe. Nothing outside the folder knows which provider is behind it.
export { StripeProvider, usePaymentGateway } from './paymentGateway';
export type {
PaymentGateway,
PaymentOutcome,
CardSetupOutcome,
PaymentSheetRequest,
} from './types';One import path for the rest of the app. Metro resolves ./paymentGateway to
paymentGateway.web.tsx on web and paymentGateway.tsx everywhere else —
platform extensions again, doing the work a conditional import would do badly.
The web implementation is honest, not fake
export function usePaymentGateway(): PaymentGateway {
return useMemo(
() => ({
payForOrder: async (): Promise<PaymentOutcome> => ({
status: 'failed',
message: UNAVAILABLE,
}),It reports failure with an explanation rather than throwing or pretending to succeed. And it sets
isReady: false, so the checkout screen renders its "payment unavailable" branch — which
is the same branch a missing Stripe key produces on a real device. One code path, testable
in a browser, that also covers a real misconfiguration.
Three things fall out of this: the web preview keeps working, the checkout screen is testable without a payment provider, and swapping Stripe for something else is one file.
Writing your own
If no library exists, Expo Modules API is the modern way — you write a Swift class and a Kotlin class, declare the functions in a small DSL, and Expo generates the JavaScript side and the typings. It is dramatically less ceremony than the old bridge-based approach.
Before you do: check whether the thing you need is already a config plugin over an existing SDK. Most "we need a native module" conversations end there.
What is next
Taking Payments with Stripe — the worked example of everything above.