Overview
While Expo provides a comprehensive SDK, sometimes you need custom native code or third-party libraries with native dependencies. Expo’s prebuild workflow makes this possible while maintaining a great developer experience.
Understanding Prebuild
Expo’s prebuild workflow generates the ios and android directories from your app.json configuration:
This creates:
ios/ - Xcode project
android/ - Android Studio project
Both are gitignored by default and regenerated as needed.
Prebuild is similar to eject but reversible. You can delete the native directories and regenerate them anytime.
When to Use Custom Native Code
Adding third-party libraries with native dependencies
Implementing features not available in Expo SDK
Integrating platform-specific SDKs (payment processors, analytics, etc.)
Building custom native modules
Modifying native build configuration
Adding Native Libraries
Install the library
npm install react-native-example-library
Run prebuild
This automatically:
Links the native module
Updates CocoaPods (iOS)
Updates Gradle dependencies (Android)
Rebuild the app
npx expo run:ios
npx expo run:android
Example: Adding react-native-maps
npm install react-native-maps
npx expo prebuild
npx expo run:ios
Then use it:
import MapView , { Marker } from 'react-native-maps' ;
export default function MapScreen () {
return (
< MapView
style = {{ flex : 1 }}
initialRegion = {{
latitude : 37.78825 ,
longitude : - 122.4324 ,
latitudeDelta : 0.0922 ,
longitudeDelta : 0.0421 ,
}}
>
< Marker
coordinate = {{ latitude : 37.78825 , longitude : - 122.4324 }}
title = "San Francisco"
/>
</ MapView >
);
}
Config Plugins
Config plugins modify the native projects during prebuild without manual editing:
{
"expo" : {
"plugins" : [
[
"react-native-maps" ,
{
"googleMapsApiKey" : "YOUR_API_KEY"
}
]
]
}
}
Common plugins
{
"expo" : {
"plugins" : [
[
"react-native-maps" ,
{
"googleMapsApiKey" : "AIza..." ,
"useGoogleMaps" : true
}
]
]
}
}
{
"expo" : {
"plugins" : [
"@react-native-firebase/app" ,
"@react-native-firebase/messaging" ,
[
"@react-native-firebase/crashlytics" ,
{
"isCrashlyticsCollectionEnabled" : true
}
]
]
}
}
{
"expo" : {
"plugins" : [
[
"react-native-iap" ,
{
"android" : {
"minSdkVersion" : 24
}
}
]
]
}
}
Creating Custom Native Modules
Using Expo Modules API
The modern way to create native modules:
Create a local Expo module
npx create-expo-module@latest --local my-module
This creates: modules/
└── my-module/
├── android/
├── ios/
├── src/
└── index.ts
Implement iOS module
modules/my-module/ios/MyModule.swift
import ExpoModulesCore
public class MyModule : Module {
public func definition () -> ModuleDefinition {
Name ( "MyModule" )
Function ( "hello" ) { ( name : String ) -> String in
return "Hello, \( name ) !"
}
AsyncFunction ( "fetchData" ) { ( url : String , promise : Promise) in
URLSession. shared . dataTask ( with : URL ( string : url) ! ) { data, response, error in
if let error = error {
promise. reject (error)
return
}
if let data = data {
let text = String ( data : data, encoding : . utf8 )
promise. resolve (text)
}
}. resume ()
}
Events ( "onChange" )
OnCreate {
// Module initialization
}
}
}
Implement Android module
modules/my-module/android/src/main/java/expo/modules/mymodule/MyModule.kt
package expo.modules.mymodule
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class MyModule : Module () {
override fun definition () = ModuleDefinition {
Name ( "MyModule" )
Function ( "hello" ) { name: String ->
"Hello, $name !"
}
AsyncFunction ( "fetchData" ) { url: String ->
// Fetch data from URL
val result = fetchDataFromUrl (url)
result
}
Events ( "onChange" )
OnCreate {
// Module initialization
}
}
private fun fetchDataFromUrl (url: String ): String {
// Implementation
return "data"
}
}
Use the module
import MyModule from '../modules/my-module' ;
export default function App () {
const [ data , setData ] = useState ( '' );
useEffect (() => {
const greeting = MyModule . hello ( 'World' );
console . log ( greeting ); // "Hello, World!"
MyModule . fetchData ( 'https://api.example.com/data' )
. then ( result => setData ( result ));
const subscription = MyModule . addListener ( 'onChange' , ( event ) => {
console . log ( 'Changed:' , event );
});
return () => subscription . remove ();
}, []);
return < Text >{ data } </ Text > ;
}
Module capabilities
Functions
Events
Constants
Views
Function ( "multiply" ) { ( a : Int , b : Int ) -> Int in
return a * b
}
AsyncFunction ( "download" ) { ( url : String , promise : Promise) in
// Async work
promise. resolve (result)
}
Events ( "onLocationChange" , "onError" )
// Send event from Swift
sendEvent ( "onLocationChange" , [
"latitude" : 37.78825 ,
"longitude" : -122.4324
])
Listen in JS: MyModule . addListener ( 'onLocationChange' , ( event ) => {
console . log ( event . latitude , event . longitude );
});
Constants ([
"PI" : Double . pi ,
"APP_NAME" : "MyApp"
])
Access in JS: console . log ( MyModule . PI ); // 3.14159...
View (MyCustomView. self ) {
Prop ( "color" ) { view, color in
view. backgroundColor = color
}
Events ( "onPress" )
}
Use in JS: < MyCustomView
color = "#FF0000"
onPress = {(event) => console.log(event)}
/>
Creating Config Plugins
Config plugins automate native configuration:
const { withDangerousMod } = require ( '@expo/config-plugins' );
const fs = require ( 'fs' );
const path = require ( 'path' );
function withCustomAndroidPlugin ( config ) {
return withDangerousMod ( config , [
'android' ,
async ( config ) => {
const filePath = path . join (
config . modRequest . platformProjectRoot ,
'app/src/main/AndroidManifest.xml'
);
let contents = fs . readFileSync ( filePath , 'utf-8' );
// Modify AndroidManifest.xml
contents = contents . replace (
'<application' ,
'<application \n android:usesCleartextTraffic="true"'
);
fs . writeFileSync ( filePath , contents );
return config ;
},
]);
}
module . exports = withCustomAndroidPlugin ;
Use it:
{
"expo" : {
"plugins" : [ "./plugins/withCustom.js" ]
}
}
Modifying Native Code Directly
If you need full control:
Run prebuild without gitignore
{
"expo" : {
"experiments" : {
"noExpoManifestInBuildDir" : true
}
}
}
npx expo prebuild
# Remove ios/ and android/ from .gitignore
git add ios/ android/
Modify native files
Edit ios/ and android/ directories directly in Xcode or Android Studio.
Commit changes
git commit -m "Add custom native modifications"
Once you commit native directories, npx expo prebuild will warn you before overwriting changes. You lose some benefits of the managed workflow.
Development Workflow
With prebuild (recommended)
# Make changes to JS/config
npx expo prebuild --clean
npx expo run:ios
With native directories
# Make changes to native code
npx expo run:ios
# Or open in Xcode/Android Studio
EAS Build with Custom Native Code
Custom native code works seamlessly with EAS Build:
{
"build" : {
"development" : {
"developmentClient" : true
},
"preview" : {
"distribution" : "internal"
},
"production" : {}
}
}
Build:
eas build --platform ios --profile production
EAS Build runs npx expo prebuild automatically.
Troubleshooting
Native module not found after installation
# Clean and rebuild
npx expo prebuild --clean
npx expo run:ios
For iOS specifically: cd ios && pod install && cd ..
npx expo run:ios
Build fails after adding native dependency
Check minimum iOS/Android version requirements
Verify the library is compatible with React Native version
Look for config plugin in library documentation
Check for peer dependency warnings
Changes to app.json not reflected
npx expo prebuild --clean
The --clean flag removes and regenerates native directories.
Custom module not updating
# Clear Metro cache
npx expo start --clear
# Clear native build cache
cd ios && rm -rf build && cd ..
cd android && ./gradlew clean && cd ..
Best Practices
Use config plugins : Automate native configuration instead of manual editing
Stay in managed workflow : Keep native directories gitignored when possible
Document native dependencies : List libraries with native code in README
Test on both platforms : Native code behavior can differ
Use Expo Modules API : Prefer it over React Native’s legacy bridge
Version lock native dependencies : Specify exact versions to avoid breaking changes
Clean rebuilds : Run npx expo prebuild --clean when in doubt
Check compatibility : Verify libraries work with your Expo SDK version
Migration from Bare Workflow
If you have existing native directories:
Backup native changes
git commit -m "Backup before migration"
Remove native directories
Add plugins for native changes
Convert manual native modifications to config plugins in app.json.
Test thoroughly
npx expo run:ios
npx expo run:android