Monorepo in Next.js using Turborepo with Remote Caching & Vercel

Surajan Shrestha
readytowork, Inc.
Published in
11 min readApr 28, 2024
Monorepo in Next.js using Turborepo with Remote Caching & Vercel

Let’s setup a Next.js Monorepo project using the popular tool Turborepo by Vercel with additional features like Remote Caching.

Turborepo is the go-to tool for creating a monorepo for a Next.js project easily with minimal setup and fast deployment due to it’s remote caching feature.

As both Next.js and Turborepo are owned by Vercel, it’s pretty easy to setup and scale accordingly. Let’s jump into it.

Tech Stack

  1. Framework: Next.js ⚡️
  2. Monorepo tool: Turborepo 🏎
  3. Deployment & CI/CD: Vercel 🚀
  4. Package manager: npm 🧳

Things we’re doing

This article contains the following topics (TL;DR):

  1. What is a monorepo?
  2. Why use a monorepo?
  3. Why use Turborepo to setup a monorepo?
  4. Setup a Next.js monorepo using Turborepo
  5. Deploy to Vercel
  6. Remote Caching

What is a monorepo?

Monorepo is a single repository consisting of multiple projects with well-defined relationships. Each project/app has its own directory within the repository.

Example: Consider someone developing a mobile app, a web app, and a backend API. These individual projects/apps can be stored and managed within a single Git repository.

Why use a monorepo?

The reasons to use a monorepo:

  1. Shared code/components: Same components can be shared across multiple projects/apps within the same repo in a well-defined way.
  2. Easier dependency management: No need to worry about version conflicts between different libraries or frameworks. All the dependencies are stored in the same place, so they’re always compatible with each other.
  3. Simplified Build and Release Process (CI/CD): With a monorepo, you can streamline the build and release process for multiple projects/apps within the same repo. It’s easier to coordinate and automate the build, testing, and deployment processes across the entire codebase.
  4. Unified Tooling and Workflows: Monorepo allows you to use a single set of tools, configurations, and workflows for the entire codebase. This simplifies the development process and ensures consistency across all the projects within the same repo.

Why use Turborepo to setup a Monorepo?

You could use other tools like Lerna, Nx, etc. to setup a monorepo but I chose Turborepo for some of these reasons:

  1. Very little configuration needed to get started on a good monorepo.
  2. If you’re using Next.js, then it works out of the box.
  3. If you’re deploying with Vercel, it ships really well with little configuration (as Next.js and Turborepo both are owned by Vercel)
  4. One of the main reasons is: REMOTE CACHING, which is a total gamechanger ⚡️.
    Using Remote Caching with Vercel, you can save yourself a large chunk of build time when you are shipping changes frequently 🔥.

Setup a Next.js monorepo using Turborepo

It’s pretty simple to setup a Next.js monorepo using Turborepo. Let’s begin:

a. Create Turbo

First, install create-turbo CLI, which will initialize a Next.js monorepo project:

npx create-turbo@latest

After this, you’ll be asked some questions:

i. Where would you like to create your turborepo?
👉 The default location is ./my-turborepo . If you select this, a new folder called “my-turborepo” will be created and the project will be initialized inside it.
But, i wanted it in my root directory so i did “dot(.)”: .

ii. Which package manager do you want to use?
👉
You’ll get a bunch of options like: npm, pnpm, yarn, etc.
Let’s choose npm.

Then, Turborepo will generate a Next.js monorepo with a bunch of files. It will also install all the dependencies that the projects inside this monorepo depend upon.

b. Explaining our new monorepo

You’ll see the following folder structure in our new monorepo:

-node_modules
-apps
-docs
-web
-packages
-eslint-config
-typescript-config
-ui
-package.json
-turbo.json
-Others...

All of these are called Workspaces. Let me explain:

i. apps/docs: A standalone Next.js with Typescript project/app.
ii. apps/web: A standalone Next.js with Typescript project/app.
iii. packages/ui: Shared UI components that can be used by any app i.e. apps/docs, apps/web, etc.
iv. packages/eslint-config: Shared Eslint configuration
v. packages/typescript-config: Shared Typescript configuration

c. Explaining “package.json” of apps/web, apps/docs & packages/ui

You can see that inside dependencies in package.json of apps/web and apps/docs, it depends upon @repo/ui. In apps/web/package.json 👇:

{
"name": "web",
"dependencies": {
"@repo/ui": "*"
}
// Others
}

Then, if we go to package.json of packages/ui, we’ll find that the name of the UI package is “@repo/ui”. In packages/ui/package.json 👇:

{
"name": "@repo/ui",
// Others
}

This means both of our apps depend upon packages/ui which has the package name of @repo/ui.

d. Explaining imports & exports

Let’s see inside apps/web/app/page.tsx, the homepage for apps/web. We’ll see that it’s importing a Button component from @repo/ui/button.

We’ll see similar import in apps/docs/app/page.tsx also.

In apps/web/app/page.tsx 👇:

import { Button } from "@repo/ui/button"; 👈
// Other imports...

export default function Page() {
// Other code...
return (
<main>
{/* Other code */}
<Button appName="web" className={styles.button}>
Click me!
</Button>
</main>
);
}

Then, if you go to packages.json of package/ui, you’ll see the exports property.
The exports property tells the apps that are importing from package/ui, where to access the UI component from.
For eg: To access the Button component, it must look inwards, inside the ./src/button.tsx file.

In package/ui/package.json 👇:

{
"name": "@repo/ui",
// Others
"exports": {
"./button": "./src/button.tsx",
"./card": "./src/card.tsx",
"./code": "./src/code.tsx"
}
}

But, this is kind of time consuming. What if we have a lot of components 🤔? It seems tedious to update package.json every single time 😵‍💫. Let’s change that.

e. Modifying “exports” in packages/ui

To solve the problem above 👆, let’s modify packages/ui. Inside packages/ui/src, create a new file called index.tsx. Using Atomic Design Principle, let’s export all of it’s sibling components: button.tsx, code.tsx, card.tsx, etc.

Inside packages/ui/src/index.tsx 👇:

export * from "./button";
export * from "./card";
export * from "./code";
// Export any other components you have...

Then, in package.json of packages/ui, let’s update the exports property. Inside packages/ui/package.json 👇:

{
"name": "@repo/ui",
// Others
"exports": {
// Now, only one line will enable to export all of our components 😎
"./components": "./src/index.tsx"
}
}

Now, let’s fix our imports inside apps/web/app/page.tsx and apps/docs/app/page.tsx.
Inside apps/web/app/page.tsx 👇 (Same thing for apps/docs application):

// Importing Button, Card, Code components all in one single line
import { Button, Card, Code } from "@repo/ui/components"; 👈
// Other imports...

export default function Page() {
// Other code...
return (
<main>
{/* Other code */}
<Button appName="web" className={styles.button}>
Click me!
</Button>
</main>
);
}

f. Explaining “turbo.json” and running commands

In our monorepo’s root directory, you’ll find a file called turbo.json. This is an important file 🚨. This is how Turborepo manages tasks/commands for the whole monorepo.

Inside turbo.json 👇, you’ll a bunch of things:

{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"]
},
"lint": {
"dependsOn": ["^lint"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}

Anything we put inside turbo.json’s pipeline property, will be a command that can be run.

You can run commands using turbo run <task> (or turbo <task> for short).

Here, we can run 3 commands: turbo run build, turbo run lint , turbo run dev OR in short form: turbo build, turbo lint , turbo dev .

One way ☝️: To do so, you’ll need to install the turbo CLI globally:

npm install turbo --global

Another way ✌️: But, there’s another way too. You could use the npx command (if you’re using npm) to use turbo CLI whenever you want without installing it globally. Like this: npx turbo run <task> (OR npx turbo <task> for short):

npx turbo run <task>

g. Running apps/projects

We currently have 2 projects: apps/docs and apps/web. We can either run each of them separately or run all of them at once.

Running all projects at once 🗂: From root directory of our monorepo, run the command: npm run dev . It will run:

i. apps/docs in localhost:3001
ii. apps/web in localhost:3000

Running one project at a time 📁: From root directory of our monorepo, run the command: npx turbo run dev --filter <app_name>. To run:

i. apps/docs use: npx turbo run dev --filter docs , will run in localhost:3001
ii. apps/web use: npx turbo run dev --filter web , will run in localhost:3000

h. Push changes to Github

Go ahead and push your changes to your Github repo. We’ll use that repo to deploy our projects later.

Deploying to Vercel

Let’s deploy our monorepo to Vercel 🚀. As our apps are in Next.js, created using Turborepo, deploying to Vercel is pretty straight-forward because all of these technologies are owned by Vercel itself.

The whole point of a monorepo is it’s flexibility in deploying each of the standalone apps inside it.

As we have two standalone apps: apps/docs & apps/web, let’s deploy each of them as an individual project in Vercel. Both will have similar CI/CD setup with just some little differences.

a. Deploying “apps/web”

If you don’t have a Vercel account, you need to create one. I suggest signing up using your Github account. Follow these steps:

i. Go to Vercel > Create or Add new project

Create or Add a new Project on Vercel

ii. Import your Git Repository
Select your Github Repo and click on Import.

Import your Git repo

iii. Configure your project
This is how your configuration should look like. Don’t worry, it’s explained below 👇:

Vercel Configuration for apps/web

a. Give your “apps/web” project a name: I called mine rnd-turborepo.

b. Choose Framework: It should be, by default, selected to Next.js. If not, you can select it from a list of given options.

c. Select Root Directory: This is the directory, your project will run from. We’re deploying “apps/web”, so select apps/web.

d. Open Build and Output Settings:
i. Build Command (IMPORTANT ⚠️): cd ../.. && turbo run build --filter=web.
It means that, from inside apps/web, go to the root directory (cd ../..) and run turbo run build --filter=web , which will build only the apps/web project.
ii. Output Directory: Let it be default i.e. Next.js default
iii. Install Command: npm install

e. Hit Deploy 🚀: Your project will be deployed in a couple of seconds ✅. You can now view your project by clicking on one of the Domains link 👇.

Successfully deployed apps/web on Vercel

iv. [SUPER IMPORTANT ⚠️] Ignored Build Step
After deployment, we have one last thing to do. For a monorepo, another really important step is setting up the Ignored Build Step.

Go to Settings tab > Git menu > Ignored Build Step section > Select Custom behavior and put the command: npx turbo-ignore — fallback=HEAD^1 into it. Looks like this:

Ignored Build Step (Super Important)

What does it do 🤔?
👉 It ensures that one app does not get built if changes are done only in another app.
For Eg:
If changes are done only in apps/web, it makes no sense to automatically build apps/docs also 🙅‍♂️. So, we need to setup Ignored Build Step in each of our monorepo projects.

b. Deploying “apps/docs”

Follows the same thing as apps/web with only a few differences:

i. Project name might be different: Mine is rnd-turborepo-docs
ii. Root Directory: apps/docs
iii. Build command will replace --filter=web with --filter=docs: cd ../.. && turbo run build --filter=docs

Same command for Ignored Build Step as given above in apps/web👆

And that’s it for Deployment using Vercel. Let’s move on to Remote Caching.

Remote Caching in Turborepo

One of the most important and popular feature of Turborepo is Remote Caching. Remote Caching is the feature that allows you to share the cached outputs of tasks across your entire team and CI/CD pipeline.

This can significantly speed up builds by eliminating the need to re-run tasks that have already been completed by someone else on your team. This is especially useful for sharing build files with your team.

Problem 😵‍💫: We know that building a Next.js app can take quite some time. With a monorepo, you have multiple Next.js apps, so building each of them might collectively take even more time.

Solution ✅: What if, we could build our app with its latest changes, store that build cache somewhere and then reuse that cache later on? Sounds cool, doesn’t it?

We have deployed our projects (apps/web & apps/docs) in Vercel. Let’s setup Remote Caching.

a. Remote Caching on Vercel

The pros of using Vercel for deployment is that when we use turbo as a build command in Vercel, it automatically creates remote cache when our project builds ✅. So, no need to reconfigure it.

Here, when we built our apps/web project, we can see that there was 0 cache for the first time. Then, Vercel uploaded our build cache. We can now use this build cache in local machine too.

Build logs in Vercel with Remote Build Cache

b. Remote Caching on our Local Machine

Now, we can build our apps in our local machine using the remote cache that was recently stored in Vercel.

i. Turbo Login
In our root directory, run the command: npx turbo login. This will authenticate the Turbo CLI with our Vercel account.
We’ll be redirected to Vercel’s authentication page, where we have to be logged in to allow authentication to our Turbo CLI.

ii. Link our Turborepo to Vercel’s Remote Cache
Run the command: npx turbo link. This will link our repo in our local machine with the remote cache stored in Vercel.

Link our Local Repo with Vercel’s Remote Cache

You’ll be asked the following questions:
- Question: Would you like to enable Remote Caching for <Our Local Repo Location>?
-Answer 👉: y
- Question: Which Vercel scope (and Remote Cache) do you want associated with this Turborepo?
-Answer 👉: Select your Vercel account name/organization. (Sometimes we might be working under multiple organizations inside Vercel, so be sure to select that organization/account name where our project is deployed in).

iii. Run local build & see the magic
Now, if you run npm run build, you’ll see that our whole monorepo will get built in under seconds or milliseconds (ms). When we see FULL TURBO, it means that our local build has fetched the remote cache stored in vercel. Now, that’s speed ⚡️.

Run build in our local repo. It fetches Remote Cache from Vercel.

If we delete our local build cache and try again, it will still load the latest cache from Vercel 🔥.
From our root directory, we can delete our local build cache using the command:

rm -rf ./node_modules/.cache/turbo

Then, re run: npm run build. We’ll see that it still fetches from remote cache and goes FULL TURBO. That’s the power of Remote Caching.

Unless we change something in our local code and run build, it will load the remote build cache from Vercel based on the changes in each project/app.

If you’re at this section, then I want to thank you for giving my article your precious time and energy. If everything worked out for you, give this article a couple of claps 👏.
If you have any queries, please feel free to comment below 💬.

Quote: “Luck is what happens when preparation meets opportunity.” — Seneca

--

--

Surajan Shrestha
readytowork, Inc.

A Software Engineer 💻 | Codes in 👨‍💻: JavaScript, TypeScript & Go | Stacks 🧠: React, React Native, Next.js, Node, Express, Go-Gin, SQL, NoSQL and AWS.