Expo or Bare React Native
Expo generates and maintains the native projects from your config; bare React Native hands you ios and android to keep by hand.
npx expo prebuild --cleanReact Native on its own gives you a framework and two native projects to look after. Expo is a layer of tooling on top: a CLI, a large set of prebuilt native modules, a config system, and EAS for building in the cloud. It is not a fork and not a subset. An Expo app is a React Native app.
How Expo handles the native projects
Expo uses continuous native generation. You describe the app in app.json, or in app.config.ts when you need logic, plus config plugins for anything that has to touch native files. npx expo prebuild generates ios/ and android/ from that description and the SDK's template, and --clean regenerates them from scratch. The folders are disposable, so create-expo-app adds them to .gitignore. Bare React Native inverts this: the folders exist from day one, you edit Info.plist and build.gradle yourself, and you merge template changes by hand on every upgrade.
Which to pick
Expo is the default answer for a new app. Choose bare when you are embedding React Native into an existing native app, or when a native change cannot be expressed as a config plugin. Expo Go is a separate question: it is a sandbox app with a fixed set of modules, good for learning. Add a library with native code and you need a development build of your own app instead.
Example
import Constants from "expo-constants";
import { StyleSheet, Text, View } from "react-native";
// expo-constants reports the app config the build was made with. It is a
// prebuilt native module: you install it and use it, with no ios/ or
// android/ edits of your own.
const config = Constants.expoConfig;
export default function BuildInfo() {
return (
<View style={styles.screen}>
{/* config?.name is shorthand for config && config.name; the ?? gives
a value to use when the left side is null or undefined. */}
<Text style={styles.name}>{config?.name ?? "unknown app"}</Text>
<Text style={styles.meta}>version {config?.version ?? "0.0.0"}</Text>
<Text style={styles.meta}>slug {config?.slug ?? "none"}</Text>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, alignItems: "center", justifyContent: "center", gap: 6 },
name: { fontSize: 20, fontWeight: "600" },
meta: { fontSize: 14, color: "#55606a" },
});
When to use it
- Start a new app with cloud builds and no Xcode project to maintain.
- Work out why a newly installed library does nothing in Expo Go and needs a development build.
- Put a React Native screen inside an existing native iOS app, where bare is the only fit.
More examples
app.config.ts, when static JSON is not enough
Everything prebuild writes into the native projects comes from this object: the display name, the bundle identifier, the package name, the plugin list. Move from app.json to this file as soon as a value depends on which variant you are building. A later stage uses the same hook to give development and production builds different names and identifiers.
import type { ConfigContext, ExpoConfig } from "expo/config";
// Expo reads this instead of app.json when the file exists. config is
// whatever app.json already said, so spreading it keeps those values.
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: process.env.APP_VARIANT === "dev" ? "Fieldnotes Dev" : "Fieldnotes",
slug: "fieldnotes",
version: "1.0.0",
ios: {
bundleIdentifier: "com.example.fieldnotes",
supportsTablet: true,
},
android: {
package: "com.example.fieldnotes",
},
plugins: ["expo-router"],
});
The two starting points, side by side
The commands show where the ownership line sits. In an Expo project you can live for months without opening either native folder, and you can still generate them for a one-off local build. In a bare project every upgrade means reading the release diff for both platforms and applying it yourself.
# Expo: the native folders are generated from your config
npx create-expo-app@latest fieldnotes
cd fieldnotes
npx expo start # Expo Go or a development build
npx expo prebuild # writes ios/ and android/ when you need them
npx expo run:ios # compile and launch locally
npx eas-cli build --platform ios # or compile in the cloud, no Mac needed
# Bare React Native: ios/ and android/ are yours from the first commit
npx @react-native-community/cli@latest init Fieldnotes
A config plugin instead of a native edit
This is the escape hatch that keeps most projects on Expo: anything you would have typed into a native file becomes code that runs on every prebuild, in every checkout and on every build machine. Shown here so you recognise the pattern when a library's install instructions mention a plugin; a later stage writes one properly.
// plugin/withCameraPermission.ts
// A config plugin runs during prebuild and edits the generated native
// project, so you never hand-edit Info.plist.
import { withInfoPlist, type ConfigPlugin } from "expo/config-plugins";
const withCameraPermission: ConfigPlugin = (config) =>
withInfoPlist(config, (plistConfig) => {
plistConfig.modResults.NSCameraUsageDescription =
"Used to photograph site notes.";
return plistConfig;
});
export default withCameraPermission;
Discussion