Skip to main content
cd ../blog
ExpoReact NativeMobileEASNative

I stopped treating Expo as the beginner option, and the native story is why

By Emeka OkezieJul 24, 20267 min read108 views

I put off writing this for a long time, because I used to be one of the people saying it. "Expo is fine for a prototype, but the second you need a real native module you'll have to eject." I repeated that for years. It was true once. It hasn't been true for a while, and I only found that out because a client project forced me back into Expo against my own advice.

I came out the other side using it for everything. Here is what actually changed, focused on the part people still doubt: the native side.

The old fear, and why it's gone

The thing everyone remembers is "you can't use custom native code." That came from the classic managed workflow, where you never had an ios or android folder to touch. If a library needed native linking, you were stuck.

Three things quietly killed that limitation:

  • Prebuild turned the native folders into generated output instead of hand-maintained source.
  • Config plugins let a library describe the native changes it needs, applied automatically.
  • The dev client let you build a custom version of the Expo Go app with your native dependencies baked in.

Put together, you can now use any native module you want and still keep the parts of Expo that made it pleasant. You don't choose between "managed" and "real." That trade is gone.

Prebuild: your native folders are build output now

This was the mental unlock for me. In a modern Expo project your ios and android directories aren't sacred hand-edited source. They're generated from your app.json and your dependencies:

npx expo prebuild

Delete them, run it again, and you get them back. That sounds small until you've spent an afternoon resolving a merge conflict inside an Xcode project file. Native config lives in one readable place, and the platform folders are reproducible.

Config plugins: change native code without touching native code

A config plugin is a function that edits your native project during prebuild. Adding a permission string, an entitlement, a Gradle line, an Info.plist key, without you ever opening Xcode or Android Studio.

Most libraries ship their own. You just list them and pass options:

app.json
{
  "expo": {
    "name": "MyApp",
    "plugins": [
      [
        "expo-camera",
        { "cameraPermission": "We use the camera to scan receipts." }
      ],
      [
        "expo-location",
        {
          "locationAlwaysAndWhenInUsePermission": "We use your location to tag entries."
        }
      ]
    ]
  }
}

That single block writes the iOS usage descriptions and the Android permissions for you, on every build, consistently. The first time I watched a permission prompt appear correctly on both platforms without me editing a plist by hand, I was sold.

The dev client: Expo Go, but with your native modules

Expo Go is the sandbox app with a fixed set of native modules. The moment you add one it doesn't include, you build your own dev client instead:

npx expo install expo-dev-client
eas build --profile development --platform ios

Now you have an app on your device that contains your exact native dependencies, but still supports fast refresh and loading JS from your dev server. You get the day-to-day developer experience of Expo Go with none of its module restrictions. This is the piece that makes "Expo can't do native" simply false.

EAS Build: native builds without babysitting a Mac

EAS Build compiles your app in the cloud. The part I didn't expect to care about, but now can't live without: I can build and submit an iOS app without owning a working Mac build environment.

eas build --platform ios --profile production
eas submit --platform ios --latest

Signing, provisioning profiles, keystores: EAS manages the credentials so you're not hand-rolling them. Builds are reproducible because they run on a clean machine every time, not on whatever state your laptop happens to be in. For a solo developer shipping to both stores, this removed an entire category of "works on my machine" pain.

Writing your own native module when you truly need one

Sometimes there's no library and you have to write Swift or Kotlin yourself. The Expo Modules API makes this far less miserable than the old bridge. You describe your module declaratively and it wires up the types across the boundary:

ios/MyModule.swift
import ExpoModulesCore
 
public class MyModule: Module {
  public func definition() -> ModuleDefinition {
    Name("MyModule")
 
    Function("greet") { (name: String) -> String in
      return "Hello, \(name)"
    }
  }
}
import MyModule from "./modules/my-module";
 
MyModule.greet("Emeka"); // "Hello, Emeka"

You get typed functions, async support, and native view components without writing the bridging boilerplate the old way demanded. It's the difference between "I could write a native module" and "I actually will."

Over-the-air updates: ship JS fixes without the stores

This is native-adjacent and genuinely changed how I ship. With expo-updates you can push JavaScript and asset changes straight to installed apps, no store review, as long as you didn't change native code:

eas update --branch production --message "Fix the checkout copy"

A typo in production used to mean a full store submission and a day or two of review. Now it's a one-line command that reaches users on next launch. You still ship native changes through the stores, but the everyday JS fixes stop being hostage to review queues.

The native batteries that come in the box

The thing I underrated most was how much native surface is already solved and maintained as first-party modules. A rough sense of what I reach for:

  • expo-notifications for push, including the native permission flow and tokens.
  • expo-camera and expo-image-picker for capture and the photo library.
  • expo-location for GPS and geofencing, foreground and background.
  • expo-secure-store for the iOS Keychain and Android Keystore, so tokens aren't sitting in plain storage.
  • expo-av / expo-video and expo-audio for media playback and recording.
  • expo-file-system for real file access, and expo-image for a genuinely fast native image component.

These aren't thin wrappers you fight with. They're the native integrations I'd otherwise be gluing together from three half-maintained community packages, kept in step with the SDK.

The New Architecture isn't a someday problem

The other quiet win: Expo stays current with React Native's New Architecture (the Fabric renderer and JSI-based native modules). Because prebuild regenerates the native projects from a known-good template, I'm not the one manually reconciling breaking native changes every upgrade. Upgrading an SDK version is a far calmer afternoon than the bad old days of bumping React Native by hand.

What I actually learned

  1. "Managed vs bare" is the wrong question now. Prebuild plus the dev client gives you native power without giving up the workflow.
  2. Read whether a library ships a config plugin. If it does, adoption is usually just adding it to plugins. If it doesn't, that's the real friction, not Expo.
  3. Set up the dev client early. Don't wait until Expo Go blocks you; start on a dev build and you'll never hit that wall.
  4. Lean on OTA updates for JS, respect the stores for native. Knowing which change is which is the whole skill.
  5. Let EAS own your credentials. Fighting signing by hand is a rite of passage worth skipping.

Why I recommend it now

I recommend Expo because it removed the tax without removing the ceiling. I get the fast, batteries-included workflow that made React Native fun, and I no longer pay for it with "but you can't do real native." I can write Swift when I have to, use any native library, build iOS in the cloud, and patch production over the air, all from one toolchain.

If the last thing you heard about Expo was "you'll have to eject eventually," that advice expired. It's the first thing I set up on a new mobile project now, and the native side is exactly why.