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
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
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
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"
]
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"
]
}
A few things worth calling out:
-
"packages/*"is a glob — it automatically picks up every folder insidepackages/as a workspace (sopackages/db,packages/typescript-config, etc. don't need to be listed individually). -
agent,history, andfeare listed explicitly because they live at the repo root, not inside a shared parent folder. - The
packageManagerfield pins the exact Bun version, which keeps builds reproducible across machines and CI. - Top-level scripts like
build,dev, andlintare simply proxies toturbo 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
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"
}
}
The important part here is the exports field:
"exports": {
"./client": "./src/index.ts"
}
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";
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"
}
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"
}
}
(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:*"
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"
}
}
agent/tsconfig.json:
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowJs": true
}
}
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"
}
}
history/tsconfig.json:
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"allowJs": true
}
}
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:*"
}
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"
}
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
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
Or, using Turborepo's own task runner (recommended once you outgrow manual scripts), just:
turbo run dev
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
Wrapping Up
The core ideas that make this setup work are worth remembering beyond just this project:
-
Declare every workspace member explicitly in the root
package.json(workspacesarray), whether it's a glob likepackages/*or a named folder likeagent. -
Use
workspace:*for any internal package dependency — it links locally instead of hitting npm. -
Use
exportsin shared packages to define clean import paths (@repo/db/clientinstead of deep relative imports). -
Extend a shared
tsconfig.jsonin every service so compiler behavior stays consistent, and override only what's service-specific. - 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)