The Challenge
I had a React web application already in production with a configured UI library, ESLint setup, and reusable components. Everything was working smoothly until the project requirements expanded: I needed to build a lightweight version of the web app and native applications for both Android and iOS.
As a solo developer, I faced a critical decision: should I create two separate repositories for these new applications, or integrate everything into a single repository?
The idea of managing multiple repositories didn't appeal to me. The project wasn't massive, and I saw real value in keeping everything together while sharing common tools and configurations. Module Federation was on the table, colleagues had used it successfully but I wanted to explore other options first.
That's when I discovered the monorepo approach and decided to give Turborepo a try.
Why Turborepo?
Several monorepo solutions existed (Nx and Lerna being the most popular) but Turborepo stood out for a few reasons. I'd heard excellent things about its performance, and knowing that Vercel backed it gave me additional confidence in its long-term viability.
More importantly, Turborepo paired perfectly with pnpm, a package manager I'd been eager to try. The combination promised shared dependencies across workspaces, dramatically reducing installation times while ensuring version alignment across all applications. This was huge for preventing future dependency conflicts.
The Architecture
I restructured my repository from having everything dumped in the root to a clean, organized structure. A simple text diagram helps visualize it instantly:
my-monorepo/
├── apps/
│ ├── web # Main web app
│ ├── web-light # Lightweight version
│ └── mobile # Capacitor app
├── packages/
│ ├── ui # Shared React components
│ ├── tsconfig # Shared TypeScript config
│ └── vite.config.ts # Shared ESLint config
└── package.json # Root package.json
The apps/ folder contains the final applications, while the packages/ folder holds all shared code. This includes the UI library with common components, theme, hooks, types or locales. This architecture gave me consistency across all three applications. The look and feel would be perfectly aligned, and any component with reuse potential could be moved to packages/ui and instantly become available everywhere.
The Perfect Stack
Three tools formed the backbone of this transformation.
Turborepo
Turborepo acts as the orchestrator with its impressive caching system that tracks task outputs and only rebuilds what's changed. Build times dropped dramatically after the initial run, tasks execute in parallel across workspaces, and the dependency graph management is intelligent enough to handle complex scenarios.
Pnpm
pnpm is an excellent choice for managing a monorepo because it optimizes dependency handling by reusing packages instead of duplicating them. This not only saves a significant amount of disk space but also reduces installation times, making the overall development process more efficient. For these reasons, moving from npm to pnpm at this point was a natural and logical step.
Capacitor
Capacitor turned out to be the most practical choice for mobile development because it allowed me to maximize code reuse without adding unnecessary complexity. Unlike React Native, which requires a separate set of components and often an abstraction layer to bridge the gap with web-based libraries, Capacitor runs the app inside a modern WebView, meaning my existing UI package built with React and CSS Modules worked immediately with no adjustments. This direct compatibility saved significant development effort, kept the architecture simple, and still delivered performance more than sufficient for a small-to-medium application focused on data display and user interaction. Backed by the Ionic team, Capacitor offered the right balance of reliability, efficiency, and simplicity, making it the natural choice for my project.
Smart Dependency Management
I moved all development dependencies (TypeScript, ESLint, Vite, etc.) to the root package.json since they are shared across all workspaces:
{
"devDependencies": {
"@types/react": "^18.2.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@vitejs/plugin-react": "^4.0.0",
"eslint": "^8.45.0",
"typescript": "^5.1.0",
"vite": "^4.4.0",
"vitest": "^0.34.0"
}
}
Each individual app only declares its runtime dependencies. This keeps package.json files clean and ensures everyone uses the same tooling versions.
Configuration Excellence
Turborepo's strength lies in allowing base configurations that extend to specific applications. Here's my TypeScript setup from one of the apps:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo",
"types": ["vite/client", "vitest/globals"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", ".eslintrc.cjs", "vite.config.ts", "vitest.config.ts"],
"exclude": ["node_modules", "dist"]
}
This same pattern extended to ESLint, Prettier, and other tooling configurations.
The Game-Changer: Cached CI/CD
The most impressive benefit came when integrating Turborepo with Husky for pre-push hooks. Running automated tests and linting across multiple applications used to be painful, often taking over 3 minutes.
With Turborepo's caching, my pre-push hook went from 3 minutes to 5 seconds when iterating on a single app. The configuration in turbo.json was straightforward:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "build/**"]
},
"lint": {
"outputs": [".eslintcache"]
},
"test": {
"dependsOn": ["typecheck"],
"outputs": ["coverage/**"]
}
}
}
The key is "dependsOn": ["^build"]. The ^ prefix tells Turborepo that this task depends on the build task of all its internal package dependencies, allowing it to intelligently cache and re-run only what's necessary.
The One Hiccup (and Its Elegant Solution)
The transition wasn't entirely smooth. I encountered an issue when building CSS Modules in the packages/ui workspace. The solution was integrating Vite as the build tool for the UI package itself. Vite's native support for CSS Modules resolved the issue immediately.
Keeping the Package Lightweight
I didn't want to bloat the UI package with bundled dependencies. The trick was using peerDependencies in its package.json and marking them as external in Rollup's configuration via Vite. This makes each consuming application responsible for providing them.
Here's the packages/ui/package.json structure:
{
"name": "@package/ui",
"version": "1.0.0",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0",
"@mantine/core": "^7.0.0"
}
}
Libraries like React, Mantine, or React Router, which depend on a global context or need to exist as a single instance (singleton) in the application, must be peerDependencies. This avoids conflicts and subtle bugs caused by having multiple versions of the same library in the final bundle.
And the corresponding Vite configuration:
// Prod
return {
plugins: [
react(),
dts({
insertTypesEntry: true,
include: ['src/**/*'],
exclude: ['**/*.test.*', '**/*.stories.*'],
outDir: 'dist',
}),
],
resolve: {
alias: {
'@package/ui': resolve(__dirname, 'src'),
},
},
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'PackageUI',
fileName: 'index',
formats: ['es', 'cjs'],
},
rollupOptions: {
external: [
// React ecosystem
'react',
'react-dom',
'react/jsx-runtime',
'react/jsx-dev-runtime',
// Rest of main dependencies
This approach kept the UI package minimal, avoided duplicate React instances, and reduced the final bundle size significantly.
Bonus Tip: Unlock HMR
The development configuration (command === 'serve') is the secret sauce for achieving lightning-fast Hot Module Replacement across your entire monorepo. By setting the alias 'package/ui': resolve(__dirname, 'src'), we're telling Vite to resolve imports directly to the source code instead of compiled bundles. This creates an instantaneous development experience where any change to a UI component automatically reflects across all applications that use it, without page reloads or state loss. You can edit a Button component in your UI package and watch it update simultaneously in any app in real-time, making monorepo development feel as smooth as working with a single application. This setup is strictly for local development; in production, applications consume the compiled packages instead.
export default defineConfig(({ command }) => {
// local
if (command === 'serve') {
return {
plugins: [react()],
resolve: {
alias: {
'@package/ui': resolve(__dirname, 'src'),
},
},
server: {
hmr: {
overlay: true,
},
},
mode: 'development',
}
}
Results
The development velocity improvement was exponential. For solo developers or small teams managing multiple related applications, the Turborepo + pnpm + Capacitor combination offers an incredibly powerful workflow. The initial setup investment its crucial, faster iterations, better code sharing, and a dramatically improved developer experience.
Top comments (0)