Article cover image

Getting started with Turborepo

  • Claudio
    Claudio
    Director of Engineering

Turborepo is an intelligent build system for JavaScript and TypeScript codebases, which makes running tasks simpler and much more efficient.

Create a new Turborepo

  • First, run npx create-turbo@latest
  • Next, choose where you'd like to create your project
  • And finally choose your package manager

This will create a bunch of workspaces in the "app" and "packages" directory.

What is a workspace

Workspace is basically 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.

Examining the package.json

First, open the package.json inside "packages/ui" folder. You'll notice that the package's name is "name": "ui" - right at the top of the file.

Next, open the package.json inside "apps/web" folder. You'll notice that this package's name is "name": "web". You'll also see that "web" depends on a package called "ui". If you're using pnpm, you'll see it's declared like this:

// ./app/web/package.json
{
  "dependencies": {
    "ui": "workspace:*"
  }
}

This means that our "web" app depends on our local "ui" package. Actually 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

If you take a look at the "web" app's home page, it's importing Button directly from "ui". To see how this works, open the package.json inside "ui". You'll notice the following attributes:

// ./packages/ui/package.json
{
  "main": "./index.tsx",
  "types": "./index.tsx"
}

main defines the entry point for this package, and types indicates where the TypeScript types are located. Looking inside ./index.tsx where everything from ./Button is exported.

Sharing configs

Let's look at the package.json in "tsconfig", this allows us to share TypeScript config across our monorepo:

// ./packages/tsconfig/package.json
{
  "name": "tsconfig",
  "files": ["base.json", "nextjs.json", "react-library.json"]
}

Here, in the files attribute, we specify three files to be exported. Packages which depend on "tsconfig" can extend these files directly. For instance, "ui" depends on "tsconfig":

// ./packages/ui/package.json
{
  "devDependencies": {
    "tsconfig": "workspace:*"
  }
}

And inside its tsconfig.json file, it imports the intended file using extends:

// ./packages/ui/tsconfig.json
{
  "extends": "tsconfig/react-library.json"
}

This pattern allows for a monorepo to share a single tsconfig.json across all its workspaces, reducing code duplication.

In a similar way, "eslint-config-custom" lets us share ESLint config across our entire monorepo, keeping things consistent no matter what project we're working on.

Understanding turbo.json

Now that we understand our repository and its dependencies, let's see how Turborepo can help. Turborepo makes running tasks simpler and much more efficient.

Let's take a look inside our root package.json:

// ./package.json
{
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint"
  }
}

We've got three tasks specified in the scripts which use turbo run. You'll notice each of these scripts are also specified in the turbo.json's pipeline attribute:

// ./turbo.json
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "lint": {
      "outputs": []
    },
    "dev": {
      "cache": false
    }
  }
}

Every task that's registered inside turbo.json can be run with turbo run <task>.

Linting with Turborepo

When we run pnpm run lint which runs turbo run lint, Turborepo will run each workspace's lint script. For instance the "web" workspace has specified its own lint script:

// apps/web/package.json
{
  "scripts": {
    "lint": "next lint"
  }
}

Now if we run the lint script again, since our code hadn't changed, Turborepo will replay the logs from the previous run, making it much faster this time.

Building with Turborepo

Let's build our project by running pnpm run build, this will run the build script on all workspaces who have included a build script. Currently, only the "docs" and "web" workspaces have specified a build script in their package.json; therefore only those will run.

Let's look inside build pipeline in turbo.json:

// ./turbo.json
{
  "pipeline": {
    "build": {
      "outputs": [".next/**"]
    }
  }
}

Specifying outputs for the build pipeline, means that when turbo finishes running that task, it'll save the output you specify in its cache.

To see this in action, delete the apps/docs/.next build folder, and run the build script again. Since Turborepo has cached the result of our previous build, it will restore the entire .next folder from the cache instantly, without having to run the build process again, making it super fast and efficient.

Running dev scripts

Only two scripts will execute, docs:dev and web:dev. Since these are the only two workspaces which specify dev. Both dev scripts are run simultaneously, starting your NextJs apps on ports 3000 and 3001.

Let's look at the dev pipeline in turbo.json:

// ./turbo.json
{
  "pipeline": {
    "dev": {
      "cache": false
    }
  }
}

Inside dev, we've specified "cache": false. This means we're telling Turborepo not to cache the results of the dev script.

Running dev script only on one workspace

By default, turbo run dev will run dev on all workspaces at once. But sometimes, we might only want to choose one workspace. Using pnpm we can add a --filter flag to filter the script to any given workspace; we can achieve the same by passing a workspace using yarn:

# running the dev script on the "docs" workspace

# pnpm
pnpm --filter docs run dev

# yarn
yarn workspace docs run dev

How to add dependencies

Similar to how we filtered the script we ran in the previous step to a specific workspace, we can add dependencies to a specific workspace by passing a filter:

# pnpm
pnpm --filter WORKSPACE_NAME add PACKAGE_NAME
pnpm --filter web add -D tailwindcss

# yarn
yarn workspace WORKSPACE_NAME add PACKAGE_NAME
yarn workspace web add -D tailwindcss

To add a dependency to the root workspace, pass -w or --workspace-root flag, without any filter:

# pnpm 
pnpm add -w PACKAGE_NAME

# yarn
yarn add -w PACKAGE_NAME

Adding Tailwindcss

We can setup our workspaces so we can share one tailwind.config.js through out our monorepo. First we can create a new workspace in the packages folder; we're going to name it "tailwind-config", this is where we keep our shared tailwindcss configuration:

// ./packages/tailwind-config/package.json

{
  "name": "tailwind-config",
  "version": "0.0.0",
  "private": true,
  "main": "index.js"
}

Next, create the tailwind.config.js and paste in your tailwind config:

// ./packages/tailwind-config/tailwind.config.js

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    // apps content
    `src/**/*.{js,ts,jsx,tsx}`,
    // packages content
    '../../packages/**/*.{js,ts,jsx,tsx}',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Looking at the content attribute, note that we've specified paths to cover both "apps" and "packages" folders so we can use tailwindcss in our projects as well as our shared "ui" package.

Next, let's add tailwind and its dependencies to our "web" workspace and extend our shared config to use tailwind:

pnpm add -D tailwindcss postcss autoprefixer
pnpx tailwindcss init -p

Let's also add our local "tailwind-config" package as a dependency to our "web" app, so we can use the shared config.

// ./apps/web/package.json

{
  "name": "web",
  "version": "0.0.0",
  "private": true,
   ...
  "devDependencies": {
    ...
    "tailwind-config": "*",
    ... 
  }
}

Now inside the newly created tailwind.config.js we can just import our shared config from our "tailwind-config" package.

// ./apps/web/tailwind.config.js

const config = require('tailwind-config/tailwind.config.js')
module.exports = config

Add the Tailwind directives to your CSS

Add the @tailwind directives for each of Tailwind’s layers to your ./styles/globals.css file.

// ./apps/web/styles/global.css

@tailwind base;
@tailwind components;
@tailwind utilities;

That's it, you can now use tailwind throughout your monorepo and share the same config between them. You can even take this further by setting up the "ui" workspace to build and output the transpiled source and compiled styles to a dist/ folder. This will make sharing one tailwind.config.js easy, and also ensures only the CSS that is used by the current application and its dependencies is generated. You can read more in the official examples from Turborepo here.

Recap

That's a wrap folks. Turborepo is an amazing tool to optimize your tasks and manage multiple packages from a single repository.