Thomas Pedot
dimanche 22 mars 2026
Turborepo + Bun Monorepo for Tauri: Desktop, Android, and Shared Packages

Most articles about Turborepo monorepos assume you're building a Next.js app with a shared component library. If you're building a Tauri desktop app — one that targets Linux, macOS, Windows, and Android from a single codebase — the picture is different. The Rust backend, the mobile build target, and the package boundary questions are all territory the standard guides don't cover.
This article documents how GitAlchemy, a native GitLab client, is structured as a Turborepo monorepo using Bun as both the package manager and runtime. The goal is to give you something concrete to copy from rather than another theoretical overview.
Why a Monorepo for a Desktop App
The case for a monorepo becomes obvious the moment you have more than one build target. GitAlchemy produces a desktop app and an Android APK from the same TypeScript/React frontend. Both targets need to call the GitLab API. Both targets use the same UI components. Both targets share the same Zustand stores.
Without a monorepo, the choices are: duplicate the code, or invent a private npm package workflow. Neither is good. A monorepo with workspace protocol lets the desktop app and the Android build import shared packages as local dependencies — no publishing, no versioning friction, no drift between targets.
The secondary benefit is enforced boundaries. When the API client lives in packages/api-client, it cannot accidentally import from apps/desktop. The dependency graph is explicit, and Turborepo's build pipeline enforces it.
The Actual Directory Structure
1.
2├── apps/
3│ ├── desktop/ — Tauri v2 app (React + Vite frontend, Rust backend)
4│ └── android/ — Android signing config and platform overrides
5├── packages/
6│ ├── api-client/ — Type-safe GitLab API client (ky, TanStack Query)
7│ ├── state/ — Zustand v5 stores
8│ ├── shared/ — Shared TypeScript types and utilities
9│ └── ui/ — shadcn/ui v4 component library
10├── turbo.json
11├── package.json — root workspace, packageManager: "bun@1.3.6"
12└── bun.lockThe root package.json declares the workspace:
1{
2 "name": "gitlab-client",
3 "private": true,
4 "packageManager": "bun@1.3.6",
5 "workspaces": ["apps/*", "packages/*"],
6 "scripts": {
7 "dev": "turbo run dev",
8 "build": "turbo run build",
9 "typecheck": "turbo run typecheck",
10 "lint": "turbo run lint",
11 "test": "turbo run test"
12 },
13 "devDependencies": {
14 "turbo": "^2.8.20",
15 "typescript": "^5.9.3"
16 }
17}Every task goes through Turborepo. You never run bun run build directly in a package directory during development — you run it from the root and let Turborepo schedule what needs to run.
Turborepo Pipeline Configuration
The turbo.json at the root defines the task graph:
1{
2 "$schema": "https://turbo.build/schema.json",
3 "ui": "tui",
4 "tasks": {
5 "build": {
6 "dependsOn": ["^build"],
7 "inputs": ["$TURBO_DEFAULT$", ".env*"],
8 "outputs": [".next/**", "!.next/cache/**", "dist/**", "out/**"]
9 },
10 "typecheck": {
11 "dependsOn": ["^build"]
12 },
13 "lint": {
14 "dependsOn": ["^build"]
15 },
16 "test": {
17 "dependsOn": ["^build"]
18 },
19 "dev": {
20 "cache": false,
21 "persistent": true
22 }
23 }
24}The "dependsOn": ["^build"] notation means: before running build in any package, first run build in all of its upstream dependencies. In practice this means packages/api-client builds before apps/desktop, which depends on it. typecheck, lint, and test also wait for the package graph to build first, since they need the compiled type declarations.
dev is marked cache: false and persistent: true because it runs a long-lived Vite dev server, not a finite task.
Why Bun over npm or pnpm
Bun is the workspace protocol and the runtime. The workspace protocol lets packages reference each other as workspace:* — Bun resolves those to the local package at install time, no symlink gymnastics required.
1{
2 "name": "@repo/state",
3 "dependencies": {
4 "@repo/shared": "workspace:*",
5 "zustand": "^5.0.9"
6 }
7}The apps/desktop package lists all three shared packages the same way:
"@repo/api-client": "workspace:*",
"@repo/shared": "workspace:*",
"@repo/state": "workspace:*"Install speed is a secondary reason. bun install --frozen-lockfile on this monorepo is fast enough that CI skips any dedicated cache warming step — the install runs as part of before_script and finishes before a pnpm equivalent would be halfway through.
Bun is also the runtime for the utility scripts in scripts/ at the root. No Node.js installation required alongside the package manager.
The packages/api-client Package
This is the package that justifies the whole structure. @repo/api-client is a standalone, type-safe GitLab API client built with ky and TanStack Query v5.
1{
2 "name": "@repo/api-client",
3 "type": "module",
4 "main": "./src/index.ts",
5 "exports": {
6 ".": "./src/index.ts"
7 },
8 "dependencies": {
9 "ky": "^1.9.0"
10 },
11 "peerDependencies": {
12 "@tanstack/react-query": "^5.90.12",
13 "react": "^19.0.0"
14 },
15 "scripts": {
16 "typecheck": "tsc --noEmit",
17 "lint": "eslint . --max-warnings 0",
18 "test": "vitest run"
19 }
20}A few things worth noting. The main and exports fields point directly at the TypeScript source file — not a compiled output. Bun handles this at dev time, and Vite handles it at build time through its TypeScript-native resolution. There is no separate build step for the packages themselves; they are not compiled independently. Turborepo's ^build dependency ensures that any package the desktop app imports from has already run its own build task before the app builds — but for these source-only packages, the build task is just a typecheck.
The API client has its own vitest.config.ts and its own tests. Those tests run independently of the desktop app. If the GitLab API client breaks, the test failure is scoped to packages/api-client, not buried in a 300-file app test run.
Sharing UI Components
@repo/ui is the shared component library. It contains the shadcn/ui v4 components used across both build targets. The package structure is intentionally minimal:
1{
2 "name": "@repo/ui",
3 "type": "module",
4 "main": "./src/index.ts",
5 "exports": {
6 ".": "./src/index.ts"
7 },
8 "peerDependencies": {
9 "react": "^19.0.0"
10 }
11}No bundling, no CSS extraction at the package level. The consumer (the desktop Vite build) handles CSS processing. shadcn/ui v4 components use Tailwind CSS v4 utility classes, and Tailwind v4's CSS-first configuration means styles are resolved at the app build layer, not the package layer. This keeps the shared package simple and avoids any CSS-in-JS build step.
CI: GitLab CI with Bun
The .gitlab-ci.yml runs lint, typecheck, and test on every push. The pipeline uses bun install --frozen-lockfile and caches the installed node_modules across jobs:
1variables:
2 BUN_VERSION: "1.3.6"
3
4default:
5 image: node:20-slim
6 before_script:
7 - npm install -g bun@$BUN_VERSION --silent
8 - bun install --frozen-lockfile
9
10.cache: &cache
11 cache:
12 key:
13 files:
14 - bun.lock
15 paths:
16 - node_modules/
17 - apps/desktop/node_modules/
18 - packages/api-client/node_modules/
19 - packages/shared/node_modules/
20 - packages/state/node_modules/The cache key is the bun.lock file. When dependencies don't change, the install step is a cache hit and the job proceeds directly to the script. Turborepo's remote cache is a further layer — tasks that produced the same outputs given the same inputs are skipped entirely, down to the task level rather than the file level.
Tauri-Specific Consideration: Rust Stays in apps/desktop
Turborepo manages the JavaScript/TypeScript layer. It does not touch Rust.
The src-tauri/ directory lives inside apps/desktop/ alongside the Vite frontend. Cargo is the build system for the Rust code. When you run bun run tauri:build from the desktop package, the Tauri CLI orchestrates both: it runs the Vite build first, then hands the output to the Rust compiler, which bundles everything into the final binary.
1apps/desktop/
2├── src/ — React frontend (TypeScript)
3├── src-tauri/ — Rust backend (Cargo.toml, Cargo.lock)
4│ ├── src/
5│ └── tauri.conf.json
6├── package.json
7└── vite.config.tsTurborepo's build task for @repo/desktop is just tsc && vite build — it produces the frontend bundle. The actual Tauri binary production (tauri build) is a separate command not wired into the Turborepo task graph, because it triggers cross-compilation and signing steps that aren't appropriate for every CI run. The split is intentional: Turborepo handles JS dependency ordering and caching, Cargo handles Rust compilation, and the Tauri CLI ties them together at release time.
Further Reading
For more on the overall architecture decisions behind GitAlchemy — why Tauri over Electron, how the Rust IPC layer works, and the three-layer state pattern — see Tauri vs Electron: How I Built a Native GitLab Client for Desktop and Android.
GitAlchemy is available for Linux, macOS, Windows, and Android (including F-Droid). Try it at gitalchemy.app.