Tuist-style rigor for your ios/ and android/ — one typed manifest, zero hand-edited Xcode or Gradle.
manifest ─▶ generators ─▶ plan (ops) ─▶ executors ─▶ ios/ + android/
(what) (the how) (declarative) (the when) (generated)
expo-workspaces replaces the pile of ad-hoc Expo config plugins that each imperatively patch one native thing (a Podfile here, an Xcode scheme there, a target via a third-party generator) with a single declarative manifest and a composable engine that turns it into deterministic native output during expo prebuild.
You describe what you want — local & remote pods, Xcode schemes, first‑party share/widget/clip targets, Swift Package Manager deps, source patches, Android signing/SDK/gradle config — in one workspace.manifest.js. The engine plans it as a set of declarative ops and applies them at the right prebuild phase. Native output stays disposable and reproducible from source of truth.
Generated
ios//android/are disposable (CNG). The manifest is the source of truth.
- Manifest-first — config lives in one typed file, not scattered across plugin options and
expo-target.config.jsfiles. - Composable — every capability is an independent package exposing a generator. Use the meta package for everything, or compose your own set.
- First‑party iOS targets — share/widget/clip extensions are synthesized in‑house (no
@bacons/apple-targetsdependency), while reusing the battle‑tested@bacons/xcodeparser under the hood. - Deterministic & idempotent — generators emit a declarative plan; executors write only tagged blocks / known files; running prebuild twice yields no diff.
- Cross‑platform — the same op model drives iOS (Podfile, pbxproj, schemes,
.xcode.env) and Android (gradle, gradle.properties, manifest).
| Package | Responsibility |
|---|---|
expo-workspaces |
Meta — the config plugin you add to app.json. Composes every generator + executor. |
@expo-workspaces/core |
Engine: op model, generator/executor registry, manifest loader, file executor. |
@expo-workspaces/ios-xcode |
pbx executor, Xcode schemes, .xcode.env, extension embed‑cycle fix. |
@expo-workspaces/ios-pods |
Local & remote CocoaPods, pod build settings, build‑phase removal. |
@expo-workspaces/ios-targets |
First‑party native target generation (share / widget / clip). |
@expo-workspaces/ios-spm |
Swift Package Manager deps (remote + local) and product links. |
@expo-workspaces/patch |
Declarative source/config file patching. |
@expo-workspaces/android |
Gradle / manifest: signing, SDK versions, deps, gradle.properties, permissions. |
1. Install — the repo root ships a self-contained bundle, so install it straight from git:
pnpm add github:gugell/expo-workspaces # public repo
# private repo (uses your SSH key):
pnpm add "expo-workspaces@git+ssh://git@github.com/gugell/expo-workspaces.git"
# pin a tag/commit: …/expo-workspaces.git#v1.0.0Working on the plugin itself? See Development for a local link.
2. Enable the plugin in app.json (register it last so its finalized mods run after other native plugins):
{
"expo": {
"plugins": [
/* ...other plugins... */
"expo-workspaces"
// or: ["expo-workspaces", { "manifestPath": "config/workspace.manifest.js" }]
]
}
}3. Author workspace.manifest.js at the app root:
/** @type {import('expo-workspaces/types').WorkspaceManifest} */
module.exports = {
manifestVersion: 1,
localPods: [
{ pod: 'MyNativeIOS/React', path: '../../../packages/my-native' },
],
schemes: [
{ name: 'MyApp Debug', configuration: 'Debug' },
{ name: 'MyApp Release', configuration: 'Release' },
],
targets: [
{
name: 'ShareExtension',
type: 'share',
bundleIdentifier: '.ShareExtension',
deploymentTarget: '16.4',
entitlements: {
'com.apple.security.application-groups': ['group.com.example.app'],
},
},
],
};4. Generate:
expo prebuildYou'll see the plan applied:
[expo-workspace] localPods → ios/Podfile
[expo-workspace] target:ShareExtension:Info.plist (unchanged) → targets/ShareExtension/Info.plist
[expo-workspace] targetsPodfileLoader → ios/Podfile
[expo-workspace] pbxproj → ios/MyApp.xcodeproj/project.pbxproj
Every field is optional except manifestVersion. Each section is owned by one capability package.
/** @type {import('expo-workspaces/types').WorkspaceManifest} */
module.exports = {
manifestVersion: 1,
// ─── iOS · CocoaPods ─────────────────────────────────────────────
localPods: [{ pod: 'MyNativeIOS/React', path: '../../../packages/my-native' }],
remotePods: [{ pod: 'Alamofire', version: '~> 5.9' }],
podBuildSettings: [
{ target: { startsWith: 'MyNativeIOS' }, settings: { ENABLE_APP_INTENTS_METADATA_GENERATION: 'NO' } },
],
removePodBuildPhases: [{ target: { regex: '.*' }, phase: 'My Phase' }],
// ─── iOS · Xcode ─────────────────────────────────────────────────
schemes: [
{ name: 'MyApp Debug', configuration: 'Debug' },
{ name: 'MyApp Release', configuration: 'Release' },
],
replaceExpoScheme: false,
xcodeEnv: { exports: { MY_FLAG: 'true' } },
fixExtensionEmbedCycle: true, // default
// ─── iOS · Native targets (share / widget / clip) ────────────────
targetsRoot: './targets', // default
targets: [
{
name: 'ShareExtension',
type: 'share',
bundleIdentifier: '.ShareExtension',
deploymentTarget: '16.4',
entitlements: { 'com.apple.security.application-groups': ['group.com.example.app'] },
buildSettings: { ENABLE_APP_INTENTS_METADATA_GENERATION: 'NO' },
},
],
// ─── iOS · Swift Package Manager ─────────────────────────────────
swiftPackages: {
remote: [
{
url: 'https://github.com/apple/swift-collections',
requirement: { kind: 'upToNextMajorVersion', minimumVersion: '1.0.0' },
products: ['Collections'],
},
],
},
// ─── Source patching (any platform) ──────────────────────────────
patches: [
{
file: 'MyApp/AppDelegate.swift',
base: 'ios',
insertAfter: { anchor: 'import Expo', text: 'import MyKit' },
},
],
// ─── Android ─────────────────────────────────────────────────────
android: {
minSdkVersion: 24,
compileSdkVersion: 35,
targetSdkVersion: 34,
permissions: ['android.permission.RECORD_AUDIO'],
dependencies: ["implementation 'androidx.work:work-runtime:2.9.0'"],
signing: { storeFile: 'release.keystore', storePassword: '…', keyAlias: 'upload', keyPassword: '…' },
},
};📖 Full field reference: docs/manifest.md
A target has two halves: declarative config in the manifest (targets[]) and source files on disk under targets/<name>/.
targets/ShareExtension/
├─ ShareViewController.swift ← compiled into the target (auto-discovered)
├─ Info.plist ← auto-generated per type if absent; yours is preserved
├─ generated.entitlements ← written from manifest `entitlements`
└─ pods.rb ← the target's CocoaPods deps
The generator attaches an Xcode file‑system‑synchronized group to the folder, so Xcode auto‑includes everything you drop in — no per‑file registration, no manual project.pbxproj edits. EAS managed code‑signing is wired automatically via extra.eas.build.experimental.ios.appExtensions.
📖 Guide: docs/ios-targets.md
loadManifest → contributeConfig → generators.generate() → ops[] → executors
- Generators turn one manifest concern into a declarative plan of ops (
writeFile,mergeBlock,appendOnce,pbx,patch,androidGradleProperty, …). - Executors apply the ops at the correct Expo mod phase: file ops in a single
withDangerousMod, pbx ops in onefinalizedpass over a shared@bacons/xcodeproject, Android ops via Expo's typed gradle/manifest mods.
Adding a capability is just shipping a new package that exports a Generator (and optionally an Executor).
📖 Deep dive + extension guide: docs/architecture.md
Built with plain TypeScript project references — tsc -b builds the whole graph in dependency order, incrementally. The capabilities are then bundled into the repo root as a single self‑contained, git‑installable expo-workspaces package (esbuild).
pnpm install # link the workspace
pnpm build # tsc -b → bundle into ./build
pnpm watch # tsc -b --watch (per-package, no bundle)
pnpm clean # tsc -b --clean + remove ./buildLayout follows the Expo monorepo convention: the bundle source meta package lives at packages/expo-workspaces (@expo-workspaces/meta, private), capabilities at packages/@expo-workspaces/* (private). The repo root is the published/git‑installable expo-workspaces package — its committed build/ is the bundled artifact consumers receive.
Schemes, targets, and all pbx edits are produced through the @bacons/xcode object model (XCScheme, PBXNativeTarget, …) — there is no XML/string templating.
Local link (to develop the plugin against an app without pushing): in the app, pnpm add link:../path/to/expo-workspaces (the repo root — run pnpm build first so ./build exists), or use a git+file:// URL.
Releasing: the git‑installable artifact is the committed build/. After any change, run pnpm build and commit the updated build/ before pushing — consumers fetch the committed bundle (there is no build‑on‑install). See .github/workflows — CI keeps build/ in sync automatically.
pnpm install- Edit sources under
packages/@expo-workspaces/*/src(orpackages/expo-workspaces/srcfor composition). - If you change the public manifest surface, mirror it in
dist-types/types.d.ts(anddist-types/index.d.tsfor new exports). These are the hand‑curated, consumer‑facing declarations the bundle ships — they are not auto‑generated from the capability types, so keep them in sync.⚠️ pnpm build—tsc -b(type‑checks + builds every package via project references) then bundles intobuild/(copyingdist-types/*in).- Commit your
srcchanges and the regeneratedbuild/. - Open a PR — CI rebuilds and fails if the committed
build/is stale. On merge tomain, CI auto‑refreshesbuild/.
Committed vs generated: the root build/ (the shipped bundle) and dist-types/ (its type source) are committed; per‑package packages/**/build/ and *.tsbuildinfo are gitignored — rebuilt locally by tsc -b.
Prefer not to commit build artifacts? The alternative is publishing to npm (build on publish,
build/gitignored). The metadata +prepackare already in place for that — see the note under Install.
| Tool | Version |
|---|---|
| Expo SDK | ≥ 56 |
| Node | ≥ 20.19.4 |
@bacons/xcode |
1.0.0-alpha.32 (pinned) |
MIT