DEV Community

Ahmad
Ahmad

Posted on

Setting Up a Monorepo Workspace with Turborepo: A Complete Guide

If you've ever worked on a project with multiple services — a frontend, a couple of backend services, and a shared database layer — you've probably felt the pain of managing them as separate repositories. Shared types drift out of sync, duplicated config everywhere, and running everything together for local development becomes a chore.

This is exactly the problem a monorepo solves, and Turborepo is one of the best tools for managing one in the JS/TS ecosystem. In this post, I'll walk through setting up a real-world Turborepo monorepo with:

  • A Next.js frontend (fe)
  • An Express-based agent backend (agent)
  • A Bun-based history service (history)
  • A shared Prisma database package (packages/db)

By the end, you'll have a working monorepo where all services share types, database access, and TypeScript config — and can be built, linted, and run with a single command.

Prerequisites

  • Node.js >= 18
  • A package manager — this guide uses pnpm for the initial scaffold and bun for running the actual services (you can adapt commands to your preferred manager)
  • Basic familiarity with npm/pnpm workspaces

Step 1: Scaffold the Turborepo

Turborepo ships with a generator that scaffolds a ready-to-go monorepo structure. Run:

npx create-turbo@latest
Enter fullscreen mode Exit fullscreen mode

You'll be walked through a short setup wizard:

Need to install the following packages:
create-turbo@2.10.3
Ok to proceed? (y) y
✔ Where would you like to create your Turborepo? someg
✔ Which package manager do you want to use? pnpm

>>> Creating a new Turborepo with:

Application packages
 - apps/docs
 - apps/web

Library packages
 - packages/eslint-config
 - packages/typescript-config
 - packages/ui
Enter fullscreen mode Exit fullscreen mode

Once it finishes, you'll have a directory that looks like this:

$ ls
apps  node_modules  package.json  packages  pnpm-lock.yaml  pnpm-workspace.yaml  README.md  turbo.json

$ ls -a
.  ..  apps  .git  .gitignore  node_modules  .npmrc  package.json  packages  pnpm-lock.yaml  pnpm-workspace.yaml  README.md  turbo.json
Enter fullscreen mode Exit fullscreen mode

This default structure ships with two example apps (docs and web) and three shared packages (eslint-config, typescript-config, ui) — a great starting point, but not our actual target structure. Let's reshape it.

Step 2: Plan Your Real Workspace Structure

For this project we need three independent services and one shared package:

Folder Purpose Runtime
fe Frontend Next.js
agent Agent backend Node/Express (via Bun)
history History backend Bun native
packages/db Shared Prisma client Used by all three above

Delete or repurpose the default apps/* folders, and create your own top-level service folders (agent, history, fe) instead of nesting everything under apps/. Turborepo doesn't require services to live under apps/ — workspaces just need to be declared correctly in your root config, which we'll do next.

Step 3: Declare Workspaces in the Root package.json

This is the piece that actually tells your package manager (and Turborepo) which folders are workspace members. Since our services live at the root level rather than under apps/, we declare them explicitly:

"workspaces": [
  "packages/*",
  "agent",
  "history",
  "fe"
]
Enter fullscreen mode Exit fullscreen mode

Here's the full root package.json:

{
  "name": "same.dev",
  "private": true,
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint",
    "format": "prettier --write \"**/*.{ts,tsx,md}\"",
    "check-types": "turbo run check-types",
    "generate:db": "cd packages/db && bunx prisma generate",
    "build:frontend": "cd fe && bun run build",
    "start:frontend": "cd fe && bun run dev",
    "start:agent": "cd agent && bun run dev",
    "start:history": "cd history && bun run dev",
    "start:everything": "bun generate:db && bun build:frontend && bun start:frontend && bun start:agent && bun start:history"
  },
  "devDependencies": {
    "prettier": "^3.6.0",
    "turbo": "^2.5.4",
    "typescript": "5.9.2"
  },
  "engines": {
    "node": ">=18"
  },
  "packageManager": "bun@1.2.11",
  "workspaces": [
    "packages/*",
    "agent",
    "history",
    "fe"
  ]
}
Enter fullscreen mode Exit fullscreen mode

A few things worth calling out:

  • "packages/*" is a glob — it automatically picks up every folder inside packages/ as a workspace (so packages/db, packages/typescript-config, etc. don't need to be listed individually).
  • agent, history, and fe are listed explicitly because they live at the repo root, not inside a shared parent folder.
  • The packageManager field pins the exact Bun version, which keeps builds reproducible across machines and CI.
  • Top-level scripts like build, dev, and lint are simply proxies to turbo run <task> — Turborepo then figures out which workspaces have that script and runs them (in parallel, with caching) rather than you calling each service manually.

At this point, your repo root should look like:

$ ls -a
.   agent     docker              .dockerignore  fe    .github     history       .npmrc        packages   .turbo
..  bun.lock  docker-compose.yml  .env           .git  .gitignore  node_modules  package.json  README.md  turbo.json
Enter fullscreen mode Exit fullscreen mode

Step 4: Set Up the Shared Database Package

Since all three services need database access, it makes sense to centralize Prisma into a single shared package rather than duplicating the schema and client in every service.

Create packages/db/package.json:

{
  "name": "@repo/db",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "exports": {
    "./client": "./src/index.ts"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "devDependencies": {
    "@repo/typescript-config": "workspace:*",
    "prisma": "^6.14.0"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "commonjs",
  "dependencies": {
    "@prisma/client": "^6.14.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

The important part here is the exports field:

"exports": {
  "./client": "./src/index.ts"
}
Enter fullscreen mode Exit fullscreen mode

This defines a named export path for the package. Instead of every service reaching into packages/db/src/index.ts with a relative path, they can simply do:

import { prisma } from "@repo/db/client";
Enter fullscreen mode Exit fullscreen mode

This is the standard pattern for sharing code across a monorepo — internal packages get a @repo/* scoped name (unrelated to npm's public registry) and are consumed the same way an external dependency would be, but resolved locally via the workspace.

Its tsconfig.json just extends the shared base config:

{
  "extends": "@repo/typescript-config/base.json"
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Wire Up Each Service

fe (Frontend — Next.js)

fe/package.json:

{
  "name": "same.dev.fe",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "generate:db": "cd .. && cd packages/db && bunx prisma generate && cd ../.."
  },
  "dependencies": {
    "next": "15.3.5",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@repo/db": "workspace:*",
    "@types/node": "^24.2.0",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "15.3.5",
    "typescript": "^5.9.2"
  }
}
Enter fullscreen mode Exit fullscreen mode

(Trimmed to the essentials above for readability — your real fe/package.json will also include UI libraries like Radix, Tailwind, and whatever else your frontend needs.)

The key line is:

"@repo/db": "workspace:*"
Enter fullscreen mode Exit fullscreen mode

The workspace:* protocol tells your package manager: "don't fetch this from npm — link it directly to the local packages/db folder in this monorepo." Any change you make in packages/db is instantly reflected in fe without publishing or reinstalling anything.

agent (Backend — Express)

agent/package.json:

{
  "name": "agent",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "tsc -b",
    "start": "node ./dist/index.js",
    "dev": "bun run build && bun start"
  },
  "devDependencies": {
    "@repo/typescript-config": "workspace:*",
    "@repo/db": "workspace:*"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "commonjs",
  "dependencies": {
    "@google/generative-ai": "^0.24.1",
    "cors": "^2.8.5",
    "dotenv": "^17.2.0",
    "express": "^5.1.0",
    "express-rate-limit": "^8.1.0",
    "groq-sdk": "^0.27.0",
    "minimatch": "^10.0.3"
  }
}
Enter fullscreen mode Exit fullscreen mode

agent/tsconfig.json:

{
  "extends": "@repo/typescript-config/base.json",
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowJs": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice the pattern: every service's tsconfig.json extends the shared @repo/typescript-config/base.json, then overrides only what's specific to that service (like rootDir/outDir for the build output). This keeps compiler settings consistent across the whole monorepo while still letting each service customize its build.

history (Backend — Bun native)

history/package.json:

{
  "name": "history",
  "module": "index.ts",
  "type": "module",
  "private": true,
  "scripts": {
    "build": "bun build ./src/index.ts --outdir dist --target bun",
    "start": "bun run dist/index.js",
    "dev": "bun ./src/index.ts",
    "generate:db": "cd .. && cd packages/db && bunx prisma generate && cd ../.."
  },
  "devDependencies": {
    "@repo/typescript-config": "workspace:*",
    "@repo/db": "workspace:*"
  },
  "peerDependencies": {
    "typescript": "^5.9.2"
  },
  "dependencies": {
    "cors": "^2.8.5",
    "express": "^5.1.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

history/tsconfig.json:

{
  "extends": "@repo/typescript-config/base.json",
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "allowJs": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Unlike agent, history runs directly on Bun (bun ./src/index.ts for dev) rather than compiling to CommonJS and running with Node — which is why its package.json uses "type": "module" and skips the tsc -b build step during development.

Step 6: The Fix Most People Miss

If you scaffold each service folder independently (rather than through a generator), it's easy to forget the one thing that actually makes the monorepo work as a monorepo: wiring the internal workspace dependencies.

Every service that needs the shared database client must declare it explicitly:

"devDependencies": {
  "@repo/typescript-config": "workspace:*",
  "@repo/db": "workspace:*"
}
Enter fullscreen mode Exit fullscreen mode

This needs to be added in agent, history, and fe individually. Without this line, TypeScript won't resolve @repo/db/client, and your package manager won't create the symlink needed for the workspace package to be importable at all — even if the folder physically exists on disk.

Similarly, every service's tsconfig.json needs to extend the shared config:

{
  "extends": "@repo/typescript-config/base.json"
}
Enter fullscreen mode Exit fullscreen mode

Skipping this means each service falls back to its own default TypeScript behavior, and you lose the whole point of centralizing compiler settings.

Step 7: Install and Run

Once every package.json and tsconfig.json is wired up, install once from the root:

bun install
Enter fullscreen mode Exit fullscreen mode

This resolves and symlinks all workspace packages in one pass. Then generate your Prisma client and bring up all services:

bun generate:db
bun start:everything
Enter fullscreen mode Exit fullscreen mode

Or, using Turborepo's own task runner (recommended once you outgrow manual scripts), just:

turbo run dev
Enter fullscreen mode Exit fullscreen mode

Turborepo will detect the dev script across fe, agent, and history, run them in parallel, and cache task outputs so repeated runs are fast.

Final Structure Recap

same.dev/
├── agent/                  # Express backend
│   ├── package.json
│   └── tsconfig.json
├── history/                # Bun backend
│   ├── package.json
│   └── tsconfig.json
├── fe/                     # Next.js frontend
│   ├── package.json
│   └── tsconfig.json
├── packages/
│   ├── db/                 # Shared Prisma client
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── typescript-config/  # Shared tsconfig base
│   └── eslint-config/      # Shared lint rules
├── package.json            # Root — declares workspaces + turbo scripts
├── turbo.json
└── pnpm-workspace.yaml / bun.lock
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

The core ideas that make this setup work are worth remembering beyond just this project:

  1. Declare every workspace member explicitly in the root package.json (workspaces array), whether it's a glob like packages/* or a named folder like agent.
  2. Use workspace:* for any internal package dependency — it links locally instead of hitting npm.
  3. Use exports in shared packages to define clean import paths (@repo/db/client instead of deep relative imports).
  4. Extend a shared tsconfig.json in every service so compiler behavior stays consistent, and override only what's service-specific.
  5. Let Turborepo's task runner (turbo run dev/build/lint) replace manually orchestrated shell scripts once your service count grows — it parallelizes and caches automatically.

With this in place, adding a fourth service later is mostly copy-paste: create the folder, add it to workspaces, wire up @repo/db and @repo/typescript-config, and you're done.

Top comments (0)