Building full stack TypeScript application with Turborepo
Getting started with monorepos using Turborepo, TypeScript, React, Node.js and pnpm
Whether you’re building a fullstack application or an application composed of multiple frontend and backend projects, you’ll probably need to share parts across projects to some extent.
It could be types, utilities, validation schemas, components, design systems, development tools setup or configurations. Monorepos help managing all those parts together in one repository.
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 starting out with monorepos or trying different tools in the ecosystem.
In this article, we’ll provide an overview of what monorepos are and what are the benefits of using Turborepo. We’ll then build a simple full stack application with React and Node.js using pnpm workspaces and demonstrate how the process can be improved by using Turborepo.
Monorepo overview
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.
Polyrepo
Let’s say we’re building a fullstack 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 that on both projects, we have to create a third repository for it and then consume it as an external package at both projects.
Each time we modify the shared package, we have to build and publish a new version, and all projects using this package should update it to the newer 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.
Monorepo tooling
Using monorepos without the right tooling, however, would make the applications more difficult to manage than using polyrepos. To have an optimized monorepo you’ll need a caching system and an optimized tasks 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.
Turborepo
Turborepo is a popular monorepo tool in the JavaScript/TypeScript ecosystem. It’s written in Go (in Rust soon) and was authored 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 be easily 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 details in the next section.
Once Turborepo is installed and configured in your monorepo, it will understand how your projects depend on each other and maximize the running speed for your tasks.
Turborepo doesn’t do the same work twice, it has a caching system that allows for the skipping of the work that has already been done before. Also it creates multiple cached versions of the files. In case you rollback to a previous file content an older cached version will be used.
Turborepo documentation is a great resource to learn more. Also the official Turborepo handbook covers important aspects of monorepos in general and related topics, like migrating to a monorepo, development workflows, code sharing, linting, testing, publishing, deployment and more.
Base monorepo setup
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 workspaces dependencies.
We’ll be using pnpm, not only for it’s efficiency, speed and disk space usage, but because it has also good support for managing workspaces and it’s recommended by the Turborepo team.
You can check 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 by the general high-level structure.
We’ll place api
, web
and types
inside a packages
directory in the monorepo root. At the root-level, we’ll also have a package.json
and a pnpm-workspace.yaml
configuration file for pnpm to specify which packages are workspaces:
. ├── 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’ll 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 pnpm init
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/workspaces at once. This configuration is written inside the root-level pnpm-workspace.yaml
file.
We’ll use a glob pattern to match all packages
’ direct 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 the directory name packages
for the directory to include our workspaces, but it can be named differently. apps
and libs
are my personal preferences (inspired by Nx).
You can also have multiple workspaces directories by adding them to pnpm-workspace.yaml
.
In the following sections we’ll setup a base project for each workspace and install their dependencies.
Shared types 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 that:
// packages/types/package.json { "name": "types", "version": "1.0.0", "main": "./src/index.ts", "types": "./src/index.ts", "scripts": { "type-check": "tsc" }, "devDependencies": { "typescript": "^4.9.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 at both api
and web
:
// packages/types/src/index.ts export type Workspace = { name: string version: string }
The shared types
workspace or any shared workspace, 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 local workspaces 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>
Note that the package name used to install and reference the types
workspace, should be named exactly as the defined name
field inside the types
workspace package.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.2" }, "devDependencies": { "@types/cors": "^2.8.13", "@types/express": "^4.17.15", "esbuild": "^0.17.0", "tsx": "^3.12.2", "types": "workspace:*", "typescript": "^4.9.4" } }
We’ll use the exact same tsconfig.json
from the types workspace.
Finally we’ll add the api
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 setup (React, TypeScript, Vite)
This is the last workspace, we’ll add it at 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.26", "@types/react-dom": "^18.0.10", "@vitejs/plugin-react": "^3.0.1", "types": "workspace:*", "typescript": "^4.9.4", "vite": "^4.0.4" } }
Again we’ll use the same tsconfig.json
file we used at types
and api
, adding only one line at compilerOptions
for Vite’s client types:
// packages/web/tsconfig.json { "compilerOptions": { // ... "types": ["vite/client"] } // ... }
Let’s now 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()], })
<!-- packages/web/index.html --> <!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Full stack TypeScript with Turborepo demo</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 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 dependant workspace has changed.
In addition, Turborepo can multitask; it schedules the tasks for maximizing the speed of executing them. You can read more about running tasks at Turborepo core concepts guide, in this guide you’ll also see a comparison between running workspace tasks by the package manager directly versus running tasks using Turborepo.
Installation and configuration
As mentioned earlier, we don’t need to modify our workspaces setup to use Turborepo. We’ll only need to install and configure it 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 be something like this:
.turbo node_modules dist
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 let’s add a turbo.json
file to the monorepo root to configure our Turborepo pipelines.
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 tasks 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
this task will be ignored for the corresponding workspace.
Those are the tasks that we want to define for our monorepo: dev
, type-check
and build
.
Let’s start defining each task with their 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 straight forward to use, but dependsOn
has multiple cases. You can learn more about all configuration options at the configuration reference
Here’s an overview of the files 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
Running tasks
In the project root package.json
, we can now add the scripts for the tasks we’ve defined. We’ll add turbo run <task>
commands to run a task across all workspaces, or we can use --filter
to select the workspaces to run for a task:
// package.json { "name": "full-stack-typescript-with-turborepo-demo", "private": true, "scripts": { "dev": "turbo run dev", "dev:api": "turbo run dev --filter api", "dev:web": "turbo run dev --filter web --filter api", "type-check": "turbo run type-check", "build": "turbo run build" }, "devDependencies": { "turbo": "^1.7.0" } }
If we now run the build
script with pnpm build
from the project root, it will run the build
script for all workspaces in the monorepo:
» pnpm build • Packages in scope: api, types, web • Running build in 3 packages • Remote caching disabled types:type-check: cache miss, executing ... types:type-check: ... api:type-check: cache miss, executing ... api:type-check: ... web:type-check: cache miss, executing ... web:type-check: ... web:build: cache miss, executing ... web:build: ... api:build: cache miss, executing ... api:build: ... Tasks: 5 successful, 5 total Cached: 0 cached, 5 total Time: 2.357s
In the subsequent run, if we haven’t made any changes, we’ll see an example of cached tasks output ⌁ FULL TURBO
:
» pnpm build • Packages in scope: api, types, web • Running build in 3 packages • Remote caching disabled web:type-check: cache hit, replaying output ... web:type-check: ... types:type-check: cache hit, replaying output ... types:type-check: ... api:type-check: cache hit, replaying output ... api:type-check: ... web:build: cache hit, replaying output ... web:build: ... api:build: cache hit, replaying output ... api:build: ... Tasks: 5 successful, 5 total Cached: 5 cached, 5 total Time: 60ms >>> FULL TURBO
Final code
You can find the full demo code on this GitHub repo and you can try it online via StackBlitz.
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 cases.
We’ve only scratched the surface of what we can do with Turborepo. You can find more examples on the Turborepo examples directory on GitHub. Skill Recordings on GitHub is also another example that has been around since Turborepo was first released.
We highly recommend you to take a look at Turborepo core concepts and the new handbook. There are also a couple of informative YouTube videos about Turborepo on Vercel’s channel.
Feel free to leave a comment on the discussion to share what you think about Turborepo, or if you have any question. Share this post if you find it useful and stay tuned for upcoming posts!