Omar Elhawary Frontend developer and Linux enthusiast interested in user/dev experience, software architecture, design systems, and functional programming

Building a full-stack TypeScript application with Turborepo

9 min read 2644

Build Full Stack TypeScript Project Turborepo


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?

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):

Monorepo-Vs-Polyrepo

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.


More great articles from LogRocket:


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](https://pnpm.io/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 [email protected] --filter <workspace>

N.B., 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 [email protected] --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 [email protected] --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):

Turborepo Running Workspace Tasks

Running the same tasks with Turborepo will result in faster and more optimized execution:

Turborepo Running Tasks Comparison With TR

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!

: 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.

.
Omar Elhawary Frontend developer and Linux enthusiast interested in user/dev experience, software architecture, design systems, and functional programming

Leave a Reply