Building a full-stack TypeScript application with Turborepo
Matt Angelosanto
Posted on November 30, 2022
Written by Omar Elhawary✏️
Whether you're building a full-stack application or an application composed of multiple frontend and backend projects, you'll probably need to share parts across projects to varying extents.
It could be types, utilities, validation schemas, components, design systems, development tools, or configurations. Monorepos help devs manage all these parts in one repository.
In this article, we will provide an overview of what monorepos are and what the benefits are of using Turborepo. We'll then build a simple full-stack application using Turborepo with React and Node.js using pnpm workspaces and demonstrate how the process can be improved by using Turborepo.
- What is a monorepo?
- Structuring the base monorepo
- Shared types package setup
- Backend setup (Express, TypeScript,
esbuild
,tsx
) - Frontend (React, TypeScript, Vite) setup
- Adding Turborepo
What is a monorepo?
A monorepo is a single repository that contains multiple applications and/or libraries. Monorepos facilitate project management, code sharing, cross-repo changes with instant type-checking validation, and more.
Turborepo is one of the best monorepo tools in the JavaScript/TypeScript ecosystem.
It's fast, easy to configure and use, independent from the application technologies, and can be adopted incrementally. It has a small learning curve and a low barrier to entry — whether you're just starting out with monorepos or are experienced and looking to try different tools in the ecosystem.
Here's a representation of the structure of a monorepo and a polyrepo (source can be found here):
Polyrepos
Let's say we're building a full-stack application; both the frontend and the backend are two separate projects, each of them placed in a different repository — this is a polyrepo.
If we need to share types or utilities between the frontend and the backend and we don't want to duplicate them on both projects, we have to create a third repository and consume them as an external package for both projects.
Each time we modify the shared package, we have to build and publish a new version. Then, all projects using this package should update to the newest version.
In addition to the overhead of versioning and publishing, these multiple parts can quite easily become out of sync with a high possibility of frequent breakages.
There are other shortcomings to polyrepos depending on your project, and using a monorepo is an alternative that addresses some of these issues.
Optimizing monorepos
Using monorepos without the right tooling can make applications more difficult to manage than using polyrepos. To have an optimized monorepo, you'll need a caching system along with optimized task execution to save development and deployment time.
There are many tools like Lerna, Nx, Turborepo, Moon, Rush, and Bazel, to name a few. Today, we'll be using Turborepo, as it's lightweight, flexible, and easy to use.
You can learn more about monorepos, when and why to use them, and a comparison between various tools at monorepo.tools.
What is Turborepo?
Turborepo is a popular monorepo tool in the JavaScript/TypeScript ecosystem. It's written in Go and was created by Jared Palmer — it was acquired by Vercel a year ago.
Turborepo is fast, easy to use and configure, and serves as a lightweight layer that can easily be added or replaced. It's built on top of workspaces, a feature that comes with all major package managers. We'll cover workspaces in more detail in the next section.
Once Turborepo has been installed and configured in your monorepo, it will understand how your projects depend on each other and maximize running speed for your scripts and tasks.
Turborepo doesn't do the same work twice; it has a caching system that allows for the skipping of work that has already been done before. The cache also keeps track of multiple versions, so if you roll back to a previous version it can reuse earlier versions of the “files” cache.
The Turborepo documentation is a great resource to learn more. The official Turborepo handbook also covers important aspects of monorepos in general and related topics, like migrating to a monorepo, development workflows, code sharing, linting, testing, publishing, and deployment.
Structuring the base monorepo
Workspaces with pnpm
Workspaces are the base building blocks for a monorepo. All major package managers have built-in support for workspaces, including npm, yarn, and pnpm.
Workspaces provide support for managing multiple projects in a single repository. Each project is contained in a workspace with its own package.json
, source code, and configuration files.
There's also a package.json
at the root level of the monorepo and a lock file. The lock file keeps a reference of all packages installed across all workspaces, so you only need to run pnpm install
or npm install
once to install all workspace dependencies.
We'll be using pnpm, not only for its efficiency, speed, and disk space usage, but because it also has good support for managing workspaces and it's recommended by the Turborepo team.
You can check out this article to learn more about managing a full-stack monorepo with pnpm.
If you don't have pnpm installed, check out their installation guide. You can also use npm or yarn workspaces instead of pnpm workspaces if you prefer.
Structure overview
We'll start with the general high-level structure.
First, we'll place api
, web
, and types
inside a packages
directory in the monorepo root. At the root level, we also have a package.json
and a pnpm-workspace.yaml
configuration file for pnpm to specify which packages are workspaces, as shown here:
.
├── packages
│ ├── api/
│ ├── types/
│ └── web/
├── package.json
└── pnpm-workspace.yaml
We can quickly create the packages
directory and its sub-directories with the following mkdir
command:
mkdir -p packages/{api,types,web}
We will then run pnpm init
in the monorepo root and in the three packages:
pnpm init
cd packages/api; pnpm init
cd ../../packages/types; pnpm init
cd ../../packages/web; pnpm init
cd ../..
Notice we used ../..
to go back two directories after each cd
command, before finally going back to the monorepo root with the cd ../..
command.
We want any direct child directory inside the packages
directory to be a workspace, but pnpm and other package managers don't recognize workspaces until we explicitly define them.
Configuring workspaces implies that we specify workspaces either by listing each workspace individually, or with a pattern to match multiple directories or workspaces at once. This configuration is written inside the root level pnpm-workspace.yaml
file.
We'll use a glob pattern to match all the packages
directly to the children directories. Here's the configuration:
# pnpm-workspace.yaml
packages:
- 'packages/*'
For performance reasons, it's better to avoid nested glob matching like packages/**
, as it will match not only the direct children, but all the directories inside the packages
directory.
We chose to use the name packages
as the directory that includes our workspaces, but it can be named differently; apps
and libs
are my personal preferences (inspired by Nx).
You can also have multiple workspace directories after adding them to pnpm-workspace.yaml
.
In the following sections, we'll set up a base project for each workspace and install their dependencies.
Shared types package setup
We'll start by setting up the types package at packages/types
.
typescript
is the only dependency we need for this workspace. Here's the command to install it as a dev dependency:
pnpm add --save-dev typescript --filter types
The package.json
should look like this:
// packages/types/package.json
{
"name": "types",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"type-check": "tsc"
},
"devDependencies": {
"typescript": "^4.8.4"
}
}
We'll now add the configuration file for TypeScript:
// packages/types/tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
},
"include": ["./src"]
}
Now that everything is ready, let's add and export the type that we'll use for both api
and web
.
// packages/types/src/index.ts
export type Workspace = {
name: string
version: string
}
The shared types
workspace, or any shared workspace for that matter, should be installed in the other workspaces using it. The shared workspace will be listed alongside the other dependencies or dev dependencies inside the consuming workspace’s package.json
.
pnpm has a dedicated protocol (workspace:<version>
) to resolve a local workspace with linking. You might also want to change the workspace <version>
to *
to ensure you always have the latest workspace version.
We can use the following command to install the types
workspace:
pnpm add --save-dev types@workspace --filter <workspace>
N.B., the package name used to install and reference the
types
workspace should be named exactly as the definedname
field inside thetypes
workspacepackage.json
Backend setup (Express, TypeScript, esbuild
, tsx
)
We'll now build a simple backend API using Node.js and Express at packages/api
.
Here are our dependencies and dev dependencies:
pnpm add express cors --filter api
pnpm add --save-dev typescript esbuild tsx @types/{express,cors} --filter api
pnpm add --save-dev types@workspace --filter api
The package.json
should look something like this:
// packages/api/package.json
{
"name": "api",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "esbuild src/index.ts --bundle --platform=node --outfile=dist/index.js --external:express --external:cors",
"start": "node dist/index.js",
"type-check": "tsc"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.1"
},
"devDependencies": {
"@types/cors": "^2.8.12",
"@types/express": "^4.17.14",
"esbuild": "^0.15.11",
"tsx": "^3.10.1",
"types": "workspace:*",
"typescript": "^4.8.4"
}
}
We'll use the exact same tsconfig.json
from the types
workspace.
Finally, we'll add the app entry and expose one endpoint:
// packages/api/src/index.ts
import cors from 'cors'
import express from 'express'
import { Workspace } from 'types'
const app = express()
const port = 5000
app.use(cors({ origin: 'http://localhost:3000' }))
app.get('/workspaces', (_, response) => {
const workspaces: Workspace[] = [
{ name: 'api', version: '1.0.0' },
{ name: 'types', version: '1.0.0' },
{ name: 'web', version: '1.0.0' },
]
response.json({ data: workspaces })
})
app.listen(port, () => console.log(`Listening on http://localhost:${port}`))
Frontend (React, TypeScript, Vite) setup
This is the last workspace we'll add and it will be located in packages/web
. These are the dependencies to install:
pnpm add react react-dom --filter web
pnpm add --save-dev typescript vite @vitejs/plugin-react @types/{react,react-dom} --filter web
pnpm add --save-dev types@workspace --filter web
The package.json
should look something like this:
// packages/web/package.json
{
"name": "web",
"scripts": {
"dev": "vite dev --port 3000",
"build": "vite build",
"start": "vite preview",
"type-check": "tsc"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^2.1.0",
"types": "workspace:*",
"typescript": "^4.8.4",
"vite": "^3.1.6"
}
}
Again, we'll use the same tsconfig.json
file we used for types
and api
, adding only one line at compilerOptions
for Vite's client types:
// packages/web/tsconfig.json
{
"compilerOptions": {
// ...
"types": ["vite/client"]
}
// ...
}
Now, let’s add the vite.config.ts
and the entry index.html
:
// packages/web/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Building a fullstack TypeScript project with Turborepo</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
And finally, here's our entry for the React application at src/index.tsx
:
// packages/web/src/index.tsx
import { StrictMode, useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { Workspace } from 'types'
const App = () => {
const [data, setData] = useState<Workspace[]>([])
useEffect(() => {
fetch('http://localhost:5000/workspaces')
.then((response) => response.json())
.then(({ data }) => setData(data))
}, [])
return (
<StrictMode>
<h1>Building a fullstack TypeScript project with Turborepo</h1>
<h2>Workspaces</h2>
<pre>{JSON.stringify(data, null, 2)}</pre>
</StrictMode>
)
}
const app = document.querySelector('#app')
if (app) createRoot(app).render(<App />)
Adding Turborepo
If your monorepo is simple, with only a few workspaces, managing them with pnpm workspaces can be totally sufficient.
However, with bigger projects, we'll need to have a more efficient monorepo tool to manage their complexity and scale. Turborepo can improve your workspaces by speeding up your linting, testing, and building of pipelines without changing the structure of your monorepo.
The speed gains are mainly because of Turborepo's caching system. After running a task, it will not run again until the workspace itself or a dependent workspace has changed.
In addition, Turborepo can multitask; it schedules tasks to maximize the speed of executing them.
N.B., you can read more about running tasks in the Turborepo core concepts guide)
Here’s an example from the Turborepo docs comparing running workspace tasks with the package manager directly versus running tasks using Turborepo (image source here):
Running the same tasks with Turborepo will result in faster and more optimized execution:
Installation and configuration
As mentioned earlier, we don't need to modify our workspace setups to use Turborepo. We'll just need to do two things to get it to work with our existing monorepo.
Let's first install the turbo
package at the monorepo root:
pnpm add --save-dev --workspace-root turbo
And let’s also add the .turbo
directory to the .gitignore
file, along with the task’s artifacts, files, and directories we want to cache — like the dist
directory in our case. The .gitignore
file should look something like this:
.turbo
node_modules
dist
N.B., make sure to have Git initialized in your monorepo root by running
git init
, if you haven’t already, as Turborepo uses Git with file hashing for caching
Now, we can configure our Turborepo pipelines at turbo.json
. Pipelines allow us to declare which tasks depend on each other inside our monorepo. The pipelines infer the tasks’ dependency graph to properly schedule, execute, and cache the task outputs.
Each pipeline direct key is a runnable task via turbo run <task>
. If we don't include a task name inside the workspace's package.json
scripts
, the task will be ignored for the corresponding workspace.
These are the tasks that we want to define for our monorepo: dev
, type-check
, and build
.
Let's start defining each task with its options:
// turbo.json
{
"pipeline": {
"dev": {
"cache": false
},
"type-check": {
"outputs": []
},
"build": {
"dependsOn": ["type-check"],
"outputs": ["dist/**"]
}
}
}
cache
is an enabled option by default; we've disabled it for the dev
task. The output
option is an array. If it's empty, it will cache the task logs; otherwise, it will cache the task-specified outputs.
We use dependsOn
to run the type-check
task for each workspace before running its build
task.
cache
and outputs
are straightforward to use, but dependsOn
has multiple cases. You can learn more about configuration options at the reference here.
Here's an overview of the file structure so far after adding Turborepo:
.
├── packages
│ ├── api
│ │ ├── package.json
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── types
│ │ ├── package.json
│ │ ├── src
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ └── web
│ ├── index.html
│ ├── package.json
│ ├── src
│ │ └── index.tsx
│ ├── tsconfig.json
│ └── vite.config.ts
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── turbo.json
What's next?
Monorepos facilitate the managing and scaling of complex applications. Using Turborepo on top of workspaces is a great option in a lot of use cases.
We’ve only scratched the surface of what we can do with Turborepo. You can find more examples in the Turborepo examples directory on GitHub. Skill Recordings on GitHub is also another great resource that has been around since Turborepo was first released.
We highly recommend that you look at Turborepo core concepts and the new handbook. There are also a couple of informative YouTube videos about Turborepo on Vercel's channel which you may find useful.
Feel free to leave a comment below and share what you think about Turborepo, or if you have any questions. Share this post if you find it useful and stay tuned for upcoming posts!
LogRocket: Full visibility into your web and mobile apps
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Posted on November 30, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.