Powered by Turborepo
Why Turborepo? We wanted to expose Crystallize App components in a public design system, speed up build times in the CI and on local machines, and create the foundation for micro frontends.
Nowadays, it can be both good and bad to jump straight away on that new hype, that new framework or tool that promises a lot of improvements and benefits. The good news is that we can always be up to date with what's new in the tech world. The bad is that we are subject to rapid changes and sometimes the sudden end of what we have just started using in our tech stack.
Today we will talk about our experience with Turborepo in our frontend stack, which powers app.crystallize.com, amongst other things.
Turborepo is a high-performance build system for JavaScript and TypeScript codebases.
We won't be explaining what Turborepo is because that is already well explained in this video and their docs. Instead, we’ll talk about our experience with it and how it's going so far.
BTW recently, we migrated our frontend to TypeScript.
Our application is a typical React project, and it includes a bunch of reusable components. Some of those components are public, like @crystallize/react-dialog and @crystallize/react-growl, and are maintained in their repositories. Other components live within the application and are not shared outside the application.
Our typical build time was about two minutes, which is only for the application.
For some time now, we've wanted to open-source a bunch of those reusable components, like buttons, inputs, and other things, without compromising on the developer experience for our app team. We see a need for others to use those, especially when creating their Apps in Crystallize.
We wanted to move away from our current shared component setup, where you need to publish the package to npm, update the app version and install it to get the latest changes. With a monorepo, the shared component package could just be a local dependency of our UI app but still be published separately on the CI when there were changes.
What does this mean in practical terms? It allows us to develop new components in the design system and immediately use them in our UI without having to go through publishing to npm and installing the latest version of the package.
This is where Turborepo comes in.
We got started by following their getting started guide, which works for any existing project setup. The Crystallize application got a new place to live, apps/web, and its Typescript and Eslint configuration were elevated to two shared packages, ready to be used by any other package or app that we knew we would add later. Then all we needed to do was to install packages, pnpm install in our case, which creates a top-level lockfile, not one per project as we’ve had in the past.
We’re using GitHub Actions pipelines, and the consequence of switching to a monorepo is that all apps and packages now share CI configuration under the root level .github/workflows. We altered the configuration for our existing CI configuration, mainly just changing the paths to reflect the app's new location in the repo.
Turborepo introduces a concept called “remote cache,” which uploads your build outputs into the cloud, making them available for everyone on the team, including you CI runner. This means that a certain version of the app or a package will only be built once. If you execute a build and Turborepo figures out that it has been built before, it will just download the artifacts of that build, which for us meant reducing two-minute build time to roughly 10 seconds for a cache-hit.
That is good not just for developer experience but could also prove to be a cost slicer for your CI setup if you are running local builds.
The app lives under apps/web, and the design system lives under packages/design-system. Whenever you want to use a component from the app, you just do the following:
import { Button } from '@crystallize/design-system'
Wait, how does that work? This is a simple configuration from pnpm, telling it that the design system is a workspace package:
App package.json
{
dependencies: {
"@crystallize/design-system": "workspace:*"
}
}
Killer developer experience right there. We’re importing files like they were published on npm, but in reality, the files are fetched from our local repo and hot-reloaded on the fly if it changes.
As we moved every package into a single repository, we could no longer use semantic-release for managing the versioning and releasing to npm, as it relies on the commit message format to determine the next version. We switched to using changeset, which is tailored for monorepos and has a very helpful Github CI bot that makes releasing new versions trivial.
Do we believe this configuration is ideal? No, there could possibly be some improvements made.
Like with everything else in web development, ongoing changes and potential improvements are being made both internally and as a result of the advancement of the tools being used.
However, for the time being, everything is going smoothly, and we are delighted with the use of Turborepo to migrate to a monorepo.