React Native – Native Modules and Config Plugins

July 31, 20264 min readUpdated 8/24/2026

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:ios

That 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 --clean

prebuild 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-doctor

Worth 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.