When I was working at e-commerce enabler startup, there are few quite days where I find myself frustrated with our polyrepo architecture. Most of our apps shared the same code and logic, in order to reduce the repetitiveness, we created an internal package/library a.k.a shared-components and publish it on npm registry.
The Problem
Itâs all good untill bug started to appear on shared-components, causing an error on app A and app B. Weâlll need to :
- Make a new commit on
shared-componentsto fix the error. - Run a publish task inside
shared-componentsto publish it to npm. - Make a new commit on
app A, bumping the version of theshared-componentsdependency - Make a new commit on
app B, bumping the version of theshared-componentsdependency - Deploy
app A - Deploy
app B
Same thing goes with releasing a new feature on shared-components, bumping version of hell. The worst part of it, we have 6-8 applications, meaning we had to repeat the bump version step 6-8 times. IMHO, itâs not a good DX(developer experience).
Captain Ray Holt describing painSolution
Fast forward, I resigned from the job (not because of our polyrepo issue obviously đ ) and along the way I found a tool that could solve the DX issue of my previous employer. Itâs called Turborepo , a high-performance build system for JavaScript and TypeScript codebases. Before we dive deep into Turborepo, I would like to discuss more about monorepo.
What is Monorepo ?
Monorepo is a single repository containing multiple projects in a single codebase. While these projects may be related, they are often logically independent and sometimes run by diferrent teams.
Monorepo Pros
- Simpler dependency management
- Consistency
- Unified CI/CD
- Unified build process
With these benefits, refering to our issue earlier, in a monorepo setup shared-components would be in the same codebase as app A and app B. Tackling a bug would be so much easier :
- Make a new commit on
shared-componentsto fix the error. - Deploy
app Aandapp B
No versioning is required, because app A and app B donât depend on the version of shared-components in npm - they depend on the version thatâs in the codebase.
Monorepo Cons
As our codebase grow, monorepo is difficult to scale up. The CI process may take longer than usual. Even though there is only one changes in app A, we had to run the entire projects task. Bummer. Worry not, that is where Turborepo came in to play.
Turborepo Caching
Turborepo solves our monorepoâs scaling problem by storing the result of all our tasks to remote cache stores, meaning that our CI never needs to do the same work twice. Letâs say we want to run a build task with Turborepo using turbo run build:
Turborepo Missing the Cache- Turborepo will evaluate the inputs to your task and turn them into a hash (e.g.
78awdk123). - Check the local filesystem cache for a matching cache artifact (e.g.
./node_modules/.cache/turbo/78awdk123.tar.zst). - If Turborepo doesnât find any matching artifacts for the calculated hash, Turborepo will then execute the task.
- Once the task is completed, Turborepo saves all specified outputs (including files and logs) into a new cache artifact, addressed by the hash.
Letâs say that you run the task again without changing any of its inputs:
Turborepo Hitting the Cache- The hash will be the same because the inputs havenât changed (e.g.
78awdk123) - Turborepo will find the cache artifact with a matching hash (e.g.
./node_modules/.cache/turbo/78awdk123.tar.zst) - Instead of running the task, Turborepo will replay the output - printing the saved logs to stdout and restoring the saved output files to their respective position in the filesystem.
Restoring files and logs from the cache happens near-instantaneously. This can reduce our build times from minutes or hours down to seconds or milliseconds.
Getting Started
In this section, we will try to create a new monorepo based on Turborepo's Getting Started docs. Also worth to noting, this section is a summary of Turborepoâs documentation rather than my own writing.
To create a new monorepo, use Turborepoâs npm package create-turbo. In this case Iâm using pnpm as my package manager.
pnpm dlx create-turbo@latest
Youâll be asked a few questions
Where would you like to create your turborepo?
Youâll be able to choose anywhere you like. The default is ./my-turborepo.
Which package manager do you want to use?
Turborepo doesnât handle package installation. Youâll be able to choose either :
Once youâve picked a package manager, create-turbo will create a bunch of new files inside the folder name you picked. Itâll also install all the dependencies that come with the basic example by default.
>>> Creating a new turborepo with the following: - apps/web: Next.js with TypeScript - apps/docs: Next.js with TypeScript - packages/ui: Shared React component library - packages/eslint-config-custom: Shared configuration (ESLint) - packages/tsconfig: Shared TypeScript tsconfig.json
Each of these is a workspace - a folder containing a package.json. Each workspace can declare its own dependencies, run its own scripts, and export code for other workspaces to use.
Understanding packages/ui
First, open ./packages/ui/package.json. Youâll notice that the packageâs name is ânameâ: âuiâ - right at the top of the file.
Next, open ./apps/web/package.json. Youâll notice that this packageâs name is ânameâ: âwebâ. But also - take a look in its dependencies.
Youâll see that âwebâ depends on a package called âuiâ. If youâre using pnpm, youâll see itâs declared like this:
{
"dependencies": {
"ui": "workspace:*"
}
}This means that our web app depends on our local ui package.
If you look inside apps/docs/package.json, youâll see the same thing. Both web and docs depend on ui - a shared component library.
This pattern of sharing code across applications is extremely common in monorepos - and means that multiple apps can share a single design system.
Understanding imports and exports
Take a look inside ./apps/docs/pages/index.tsx. Both docs and web are Next.js applications, and they both use the ui library in a similar way:
import {Button} from "ui";
// ^^^^^^ ^^
export default function Docs() {
return (
<div>
<h1>Docs</h1>
<Button />
</div>
);
}
Theyâre importing Button directly from a dependency called ui! How does that work? Where is Button coming from?
Open packages/ui/package.json. Youâll notice these two attributes:
{
"main": "./index.tsx",
"types": "./index.tsx"
}
When workspaces import from ui, main tells them where to access the code theyâre importing. types tells them where the TypeScript types are located.
So, letâs look inside packages/ui/index.tsx:
import * as React from "react";
export * from "./Button";Everything inside this file will be able to be used by workspaces that depend on ui.
index.tsx is exporting everything from a file called ./Button, so letâs go there:
import * as React from "react";
export const Button = () => {
return <button>Boop</button>;
};
Weâve found our button! Any changes we make in this file will be shared across web and docs. Pretty cool!
Building with Turborepo
Letâs try running our build script:
pnpm buildOnly apps/docs and apps/web specify a build script in their package.json, so only those are run.
Take a look inside build in turbo.json. Thereâs some interesting config there.
{
"pipeline": {
"build": {
"outputs": [".next/**", "!.next/cache/**"]
}
}
}
Youâll notice that some outputs have been specified. Declaring outputs will mean that when turbo finishes running your task, itâll save the output you specify in its cache.
Both apps/docs and apps/web are Next.js apps, and they output builds to the ./.next folder.
Letâs try something. Delete the apps/docs/.next build folder.
Run the build script again. Youâll notice:
We hit FULL TURBO - the builds complete in under 100ms.
The .next folder re-appears!
Turborepo cached the result of our previous build. When we ran the build command again, it restored the entire .next/** folder from the cache. To learn more, check out Turborepoâs docs on cache outputs.
Running dev script
Letâs now try running dev.
pnpm devYouâll notice some information in the terminal:
- Only two scripts will execute -
docs:devandweb:dev. These are the only two workspaces which specifydev. - Both
devscripts are run simultaneously, starting your Next.js apps on ports3000and3001. - In the terminal, youâll see
cache bypass, force executing.
Try quitting out of the script, and re-running it. Youâll notice we donât go FULL TURBO. Why is that?
Take a look at turbo.json:
{
"pipeline": {
"dev": {
"cache": false,
"persistent": true
}
}
}
Inside dev, weâve specified "cache": false. This means weâre telling Turborepo not to cache the results of the dev script. dev runs a persistent dev server and produces no outputs, so there is nothing to cache. Learn more about it in Turborepoâs docs on turning off caching.
Additionally, we set "persistent": true, to let turbo know that this is a long-running dev server, so that turbo can ensure that no other tasks depend on it. You can read more in the docs for the persistent option.
Running dev on only one workspace at a time
By default, turbo dev will run dev on all workspaces at once. But sometimes, we might only want to choose one workspace.
To handle this, we can add a --filter flag to our command.
pnpm dev --filter docsYouâll notice that it now only runs docs:dev. Learn more about filtering workspaces from Turborepoâs docs.
The End
Turborepo has helped me and my team to excel our productivity, not only preventing us from doing repetitive tasks, Turborepo has improve our CI time 3x faster. Also worth noting, this blog post doesnât cover the entire usage of Turborepo, I encourage you to visit their docs for more advanced usage. Massive thanks to Vercel and Turbo team, that will be all for me. Thank you for reading my blog post!
Naofal signing out.
