- Published on
Transitioning from Monolith to Monorepo with Turborepo
- Authors
- Name
- Thomas Quan
- Reading time
Reading time
8 min read
Transitioning from Monolith to Monorepo with Turborepo
When I started out as a complete noob in software development, all I knew and worked with were monoliths. Back then, monoliths seemed like the perfect approach. Everything was in one place, simple to manage, and easy to reason about—at least at first.
I joined a project called Vanos Insulation at 44 North Digital Marketing. Over time, that project grew into a full-fledged ERP system called Appello. As Appello grew in size, complexity, and the number of users we served, We started to notice some real pain points and issues creeping into our workflow.
Managing Shared Dependencies: The Breaking Point
Managing shared dependencies became our biggest challenge. Our utility functions and UI components were shared between frontend and mobile applications, but updating them was painful. Each change required publishing new npm packages, reinstalling dependencies, and extensive testing across all applications. This process was not only time-consuming but also error-prone, often leading to version mismatches and inconsistencies between platforms.
As the project scaled and we acquired more clients, these problems became harder to manage. Merge conflicts skyrocketed, and maintaining a consistent development experience felt like an uphill battle.
Exploring Solutions: Microservices, Micro-Frontends, and Monorepos
We explored solutions like microservices and micro-frontends, but these felt like overkill for our needs. Implementing them would have required a significant time and financial investment, which we couldn't afford.
That’s when our CTO introduced Turborepo, a monorepo tool designed to streamline development. Turborepo promised to simplify our workflow, reduce merge conflicts, and improve maintainability and reusability.
Why Turborepo?
Turborepo provides a simple and intuitive project structure:
- Apps: Contains applications such as frontend, mobile, or backend APIs.
- Packages: Contains reusable modules like UI components, shared utilities, or specialized handlers like PDF export functions.
This structure not only improves project readability but also makes it easier for developers to onboard and contribute quickly. Here's an example of what a Turborepo folder structure might look like:
root/
├── apps/
│ ├── web/ # Frontend app
│ ├── mobile/ # Mobile app
│ └── api/ # Backend API
├── packages/
│ ├── ui/ # Shared UI components
│ ├── utils/ # Utility functions
│ └── pdf-export/ # Specialized PDF handlers
└── turbo.json # Turborepo configuration
Challenges and Lessons Learned
While transitioning to Turborepo was a game-changer for us, it wasn’t without its challenges. One of the biggest pain points we faced, and I have face in my recent project photo.thomasquan.dev was environment variable management.
The Problem with Environment Variables
Turborepo's documentation provides some guidance on handling environment variables, but the solutions felt cumbersome:
Per-app .env files: This approach required manually adding environment variables to each app or package, which was tedious and error-prone. Inline export in scripts: Adding export VARIABLE=value directly into scripts worked but cluttered the configuration and lacked scalability.
The documentation suggested using a third-party tool like dotenv, which turned out to be the most practical solution. However, there were no clear instructions on integrating dotenv effectively in a monorepo setup.
A Better Way to Handle Environment Variables
After some experimentation, I landed on a setup that worked seamlessly across the monorepo. Here’s how to implement it:
- Install
dotenv-cli
as a dev dependency: Run the following command in the root directory:
npm install dotenv-cli --save-dev
- Update the root
package.json
: Modify your scripts to includedotenv --
, which passes environment variables from a.env
file:
{
"scripts": {
"dev": "dotenv -- turbo run dev"
}
}
- Configure
turbo.json
: Use the env property to specify which environment variables to propagate to your tasks. For example:
{
"tasks": {
"dev": {
"cache": false,
"persistent": true,
"env": [
"DB_USER",
"DB_PASSWORD",
"DATABASE_URL",
"NEXT_PUBLIC_SITE_DOMAIN",
"JWT_SECRET",
"GITHUB_CLIENT_ID",
"GITHUB_CLIENT_SECRET",
"R2_ACCESS_KEY_ID",
"R2_SECRET_ACCESS_KEY",
"R2_ACCOUNT_ID",
"R2_BUCKET_NAME",
"NEXTAUTH_URL",
"NEXTAUTH_SECRET",
"NEXT_PUBLIC_APP_URL"
]
}
}
}
This setup ensures that each app or package only receives the environment variables it needs, simplifying management and reducing the risk of accidental exposure.
The Upsides of Using Turborepo
Transitioning to a monorepo with Turborepo wasn’t just about solving problems—it unlocked several advantages that completely changed how we approached development. Let me walk you through some of the key benefits we experienced.
1. Simplified Dependency Management
One of the biggest wins was how Turborepo made dependency management a breeze. Previously, if we needed to update a shared package (like a utility library), we had to:
- Make the changes.
- Publish a new version to the npm registry.
- Update the dependency in every project that relied on it (frontend, backend, mobile, etc.).
With Turborepo, shared packages (like packages/shared-utils
) live within the same repository. Now, when we make updates to the shared utility package, those changes are immediately available to all dependent applications—frontend, backend, mobile apps, and even specialized tools like PDF generators.
All it takes is rebuilding the shared package, and everything is in sync. No more npm publishes or dependency mismatches! This drastically reduced the overhead of maintaining shared logic across multiple projects.
2. One Source of Truth for Shared Utilities
Before Turborepo, we often struggled with duplicated or diverging logic. For example, the frontend and backend might each have their own slightly different version of the same utility function. This led to:
- Inconsistent behavior across apps.
- Extra maintenance work to update similar code in multiple places.
Now, shared utilities like packages/shared-utils
live in a single location, serving as a central source of truth for all our projects. If the mobile app, frontend app, or backend API needs to use the same date formatter or a custom validation function, they all pull it from the same place.
This centralization eliminated duplication, reduced bugs, and ensured consistency across the entire system.
3. Reusability Across Multiple Projects
Turborepo’s structure makes it incredibly easy to reuse not just utility functions but also larger modules like UI components, APIs, or specialized tools. For instance:
- UI Components: With a shared
packages/ui
package, we can build a React button or dropdown component once and reuse it across our frontend, mobile app, and admin dashboard. - PDF Generators: We have a
packages/pdf-export
package that handles generating PDFs. Any app that needs this functionality—whether it’s the backend API or a standalone utility script—can tap into this shared module.
The ability to reuse these modules across multiple projects saves development time and ensures consistency across the system.
4. Streamlined Development Workflow
With everything in one monorepo, the workflow becomes much more streamlined. For example:
- Code Sharing: Changes to shared code are immediately reflected everywhere they’re used.
- Code Reviews: A single pull request can include updates to both a shared package and the apps that depend on it. This makes it easier to understand and approve changes holistically.
- CI/CD Efficiency: Turborepo’s built-in caching and incremental builds mean we only rebuild what’s necessary, saving time in CI pipelines.
5. Improved Collaboration
In a monorepo, all the code is in one place, which fosters collaboration. Developers working on the frontend and backend no longer have to coordinate across separate repositories. If a feature requires changes to both, those changes can happen side by side in the same branch.
This has also made onboarding new team members easier. Instead of navigating multiple repositories, they only need to learn the structure of one monorepo.
In Action: An Example
Here’s a real-world example to illustrate the impact:
We built a utility function for generating unique slugs, which was originally duplicated across our frontend and backend. Now, that logic lives in packages/shared-utils
.
- Frontend: Uses the slug generator to create slugs for new blog posts in real-time.
- Backend: Uses the same function to validate and ensure slugs are unique when saving to the database.
- Mobile App: Can reuse the exact same logic for features like offline drafts.
This centralization ensures consistent behavior across all platforms, and we only need to update the function in one place when making improvements.
Lessons Learned and Final Thoughts
Looking back, transitioning to a monorepo structure has been a game-changer for us. The ease of managing shared dependencies, having a single source of truth, and the ability to reuse code across multiple projects has saved us countless hours of development time while making our system more reliable and maintainable.
If there’s one big lesson I’ve learned, it’s that investing in a better project structure pays off in the long run. While monoliths worked great when things were small, they started to crack under the weight of growing complexity. Tools like Turborepo provide the structure and flexibility needed to scale, without the overhead of more drastic architectural shifts like microservices.
Turborepo isn’t without its flaws (looking at you, environment variables), but those pain points are far outweighed by the benefits.
If you’re working on a growing project and starting to feel the strain of managing multiple apps or packages, I highly recommend giving Monorepo a try. It’s worth the effort.