Thomas Pedot
dimanche 22 mars 2026
Migrating from Expo to Tauri: How F-Droid Requirements Changed Our Stack

Most migration articles start with a preference. This one starts with a constraint.
GitAlchemy is a native GitLab client. It started as an Expo app because Expo is a genuinely good choice for mobile-first development with React. It ended up as a Tauri v2 app because F-Droid exists, and because F-Droid and Expo cannot coexist. This is the story of that migration — what it cost, what it gained, and when it makes sense for someone else to do the same.
What Expo Gave Us
For the first version of GitAlchemy, Expo was a reasonable foundation. The developer experience is excellent: Expo Go for live reload, EAS Build for cloud compilation, a mature ecosystem of packages for camera, file system, notifications, and networking. React Native's component model is familiar if you already know React, and the path from idea to working Android APK is short.
The original expo-gitlab-client had a working app. Users could browse GitLab projects, review merge requests, track issues, and monitor CI pipelines from their phones. The codebase was entirely TypeScript. The iteration loop was fast.
Why Expo Had to Go
F-Droid is the open-source Android app repository. Unlike the Play Store, F-Droid builds every application from source code rather than accepting pre-compiled APKs. This is the core of its reproducible build guarantee: any user can verify that the binary they're installing matches the public source code.
Expo's JavaScript runtime is Hermes, a bytecode-compiled engine that Meta maintains for React Native. Hermes ships as a pre-compiled binary. F-Droid's build pipeline requires that every component of an application be compilable from source — and Hermes is not. The incompatibility is architectural, not accidental. There is no workaround, no flag, no compatibility mode. If you want your app on F-Droid and you're using Expo, you have to drop Expo.
For GitAlchemy, F-Droid distribution is not a nice-to-have. It's the primary distribution channel for open-source developer tools on Android — the users who care about open-source software are exactly the users most likely to run F-Droid instead of the Play Store. Losing F-Droid access would mean losing the audience the app was built for.
Evaluating the Alternatives
Once Expo was off the table, we evaluated four paths:
Flutter. Cross-platform, F-Droid compatible, large ecosystem. The dealbreaker: Dart. Rewriting ~31,800 lines of TypeScript into a new language is not a migration — it's a new product. We would lose the entire codebase.
Kotlin and Swift. Native apps for each platform. Excellent tooling, best-in-class performance, full F-Droid compatibility. Also two separate codebases, two different languages, and no path to desktop support without a third codebase. We would be building three apps where we had one.
React Native without Expo. This is less obvious than it sounds. Expo is a layer on top of React Native, not React Native itself. Stripping Expo and running bare React Native would eliminate the Hermes dependency — but Hermes is now the default runtime for React Native as well, and configuring bare React Native without Hermes is increasingly unsupported. More fundamentally, bare React Native is still a mobile-only framework. We would solve the F-Droid problem but not gain desktop support.
Tauri v2. Tauri uses a different architecture entirely: a Rust backend, a system WebView for rendering, and a TypeScript/React frontend. Tauri v2, released in 2024, added Android and iOS targets alongside the desktop builds. Critically, the build process is standard — no pre-compiled runtime binaries, no Hermes, no F-Droid conflicts. The existing codebase was React components and API calls. Most of it could be migrated.
What the Migration Actually Involved
The conceptual model of the migration is straightforward: replace React Native components with web components, replace Expo APIs with Tauri plugins, and wire up the frontend to a Rust backend instead of a native bridge.
In practice, this meant:
Component replacement. React Native uses platform-specific components — View, Text, TouchableOpacity, FlatList. Web-target components use standard HTML elements and CSS. We replaced the React Native component layer with shadcn/ui on top of Tailwind CSS v4. This is not a one-to-one swap; layout logic written for flexbox-in-React-Native works differently from flexbox-in-CSS. It required auditing every screen.
The evidence of this transition is still visible in the codebase. The src/components/gitlab-expo/ directory contains components that were carried over from the Expo era — the naming preserves their origin. Some router entries still show a ComingSoon component with the note "This screen is being migrated from the mobile app," reflecting that not every screen completed the migration simultaneously.
API replacement. Expo provides packages for filesystem access, secure storage, notifications, and networking. In Tauri, these become Rust commands exposed through the IPC bridge. The apps/desktop/src-tauri/Cargo.toml lists the full plugin set: tauri-plugin-fs, tauri-plugin-notification, tauri-plugin-clipboard-manager, tauri-plugin-dialog, tauri-plugin-http, tauri-plugin-log, tauri-plugin-global-shortcut, and tauri-plugin-window-state. Each one replaced a corresponding Expo package. For macOS specifically, the codebase uses tauri-nspanel (loaded via a git dependency pointing to ahkohd/tauri-nspanel) to get native NSPanel behavior — a floating panel that can appear above full-screen windows and dismisses on click-outside. That kind of platform-specific polish does not exist in Expo.
Monorepo structure. The project is organized as a Turborepo + Bun workspace with four shared packages: @repo/api-client (GitLab REST calls via ky, with TanStack Query hooks), @repo/state (Zustand v5 stores), @repo/shared (types and utilities), and @repo/ui (shared components). The apps/ directory contains desktop/ and android/ — both built from the same React frontend. Running bun run tauri android dev targets an Android emulator; bun run tauri:dev targets the host desktop. Same code, different Tauri target.
Build tooling. Expo's Metro bundler is replaced by Vite 7 on the frontend and Cargo on the Rust side. Vite's build performance is significantly better than Metro, and TypeScript strict mode is easier to enforce in a pure web project. The CI pipeline (.gitlab-ci.yml) runs lint with Biome, typecheck with tsc --noEmit, and the full test suite with coverage on every push — no Rust compilation needed in CI because the check jobs only cover the TypeScript layer.
Type safety at the IPC boundary. The Rust-TypeScript boundary is the most architecturally significant part. We use specta and tauri-specta to auto-generate TypeScript bindings from Rust command signatures. The src/lib/bindings.ts file is generated by running cargo test export_bindings -- --ignored in the src-tauri/ directory. If a Rust function signature changes, the TypeScript binding regenerates and the TypeScript compiler catches any mismatch in the calling code. The src/lib/tauri-bindings.ts wraps these generated bindings for use in the app. This eliminates an entire class of runtime errors that would otherwise only surface on the device.
The Monorepo in Practice
The workspace has two app targets and four packages:
1apps/
2 desktop/ ← Vite + Tauri (compiles to macOS, Linux, Windows, Android)
3 android/ ← Android-specific configuration
4packages/
5 api-client/ ← GitLab API, ky, TanStack Query hooks
6 shared/ ← Shared types and utilities
7 state/ ← Zustand stores
8 ui/ ← Shared UI componentsTurborepo's task graph ensures packages build before apps that depend on them. bun run dev from the root starts all dev servers in parallel via turbo run dev. This structure was not possible in the Expo project — the mobile-first toolchain made extracting shared logic into typed packages difficult.
What We Gained
The migration produced outcomes we did not initially plan for.
F-Droid distribution was the goal. We got it. GitAlchemy is now listed on F-Droid and builds cleanly from source.
Desktop support was an unexpected benefit. The same React frontend that compiles to an Android APK also compiles to a Linux, macOS, and Windows application. The desktop installer is 3.2 MB — a direct consequence of using the system WebView rather than bundling Chromium. We went from one mobile target to four build targets (Linux, macOS, Windows, Android) without maintaining separate codebases.
The Rust backend provides compile-time guarantees for the native layer. File I/O, keyboard shortcuts (including a global quick-pane shortcut configurable by the user), menu integration, native notifications, auto-updater, and window state persistence are all handled in 1,265 lines of Rust. Binary size is optimized in release builds via codegen-units = 1, lto = true, opt-level = "s", and strip = true in Cargo.toml's [profile.release] section.
TypeScript strict mode across the entire frontend is enforced by CI. Biome handles linting and formatting. Custom ast-grep rules (sgconfig.yml) catch structural anti-patterns. knip detects unused exports. The desktop app has 53 test files under Vitest.
What We Lost
This migration is not free, and saying otherwise would be dishonest.
Expo's OTA updates. Expo allows pushing JavaScript bundle updates to users without going through a store. Tauri has no equivalent. Every update requires a new APK build. For F-Droid specifically, this also means waiting for the F-Droid build process to pick up the new release — typically a few days. For users who need rapid patches, this is a meaningful regression.
The React Native ecosystem. React Native has libraries for things Tauri does not. Camera access, biometric authentication, deep linking, and push notifications are all more mature in the React Native ecosystem. In Tauri, these require either Rust plugins that may not exist yet or platform-specific wrappers written from scratch.
Mobile developer experience. Expo Go and the Expo development client make mobile iteration fast. Tauri's mobile development loop — cross-compilation, Android emulator setup, NDK configuration, signing configuration — is rougher. The android:build script in package.json exports ANDROID_HOME and NDK_HOME, dynamically locating the installed NDK version, before invoking the Tauri build. This works, but the setup is less guided than Expo's managed workflow.
When to Stay on Expo, When to Migrate
Stay on Expo if F-Droid is not a distribution requirement, if you need OTA updates, if mobile is your only target, or if you do not want to maintain any Rust code.
Consider migrating if F-Droid compatibility is a hard requirement, if you want to ship desktop and mobile from a single codebase, if the 3.2 MB vs 150+ MB installer difference matters to your users, or if you are already using React and TypeScript and want to extend rather than rewrite.
The Expo-to-Tauri migration is not a general recommendation. It is the right answer for a specific set of requirements — the requirements GitAlchemy had.
Further Reading
The full architecture of the Tauri v2 stack — Rust IPC, the three-layer state pattern, monorepo structure, and Tauri vs Electron trade-offs — is covered in depth in Tauri vs Electron: How I Built a Native GitLab Client for Desktop and Android.
GitAlchemy is available for Android (including F-Droid), Linux, macOS, and Windows. Try it at gitalchemy.app.