If you have ever opened a full stack repository and felt lost within the first ten seconds, you already know why a solid monorepo full stack project structure matters. A good layout tells the story of your project before you read a single line of code. In this guide, we walk through a clean, production ready way to organize a JavaScript or TypeScript monorepo using Turborepo and pnpm workspaces, with shared packages, frontend, backend, and tooling all living happily together.
This is not a theoretical post. You will get the exact folder tree, the configuration files you need to copy, and the reasoning behind every decision so you can adapt it to your own stack.
Why Use a Monorepo for a Full Stack Project
Before jumping into folders and configs, let’s quickly cover why this approach is worth it.
- Shared code: types, validation schemas, UI components, and utilities live in one place.
- Atomic changes: update an API contract and its frontend consumer in a single pull request.
- Consistent tooling: one ESLint config, one TypeScript config, one CI pipeline.
- Faster builds: Turborepo caches tasks locally and remotely, so unchanged packages are not rebuilt.
- Easier onboarding: new developers clone one repo and have everything running.
Turborepo vs Other Monorepo Tools
You have several options. Here is a quick comparison so you understand why we land on Turborepo + pnpm.
| Tool | Best For | Learning Curve | Caching |
|---|---|---|---|
| Turborepo | JS/TS full stack apps | Low | Local + Remote |
| Nx | Large enterprise apps | Medium to High | Local + Remote |
| Lerna | Library publishing | Low | Limited |
| Bazel | Polyglot mega projects | High | Excellent |
For a JavaScript or TypeScript full stack project, Turborepo hits the sweet spot of speed, simplicity, and zero ceremony.
The Recommended Folder Structure
Here is the structure we use and recommend. It separates apps (deployable things) from packages (reusable things) and keeps tooling neatly grouped.
my-monorepo/
├── apps/
│ ├── web/ # Next.js frontend
│ ├── api/ # Express or Fastify backend
│ └── admin/ # Optional admin dashboard
├── packages/
│ ├── ui/ # Shared React components
│ ├── shared/ # Shared types, schemas, utils
│ ├── database/ # Prisma client and schema
│ └── config/ # Shared runtime config
├── tooling/
│ ├── eslint-config/ # Shared ESLint config
│ ├── tsconfig/ # Shared tsconfig presets
│ └── tailwind-config/ # Shared Tailwind preset
├── .github/
│ └── workflows/ # CI pipelines
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
├── tsconfig.base.json
└── README.md
Why This Layout Works
- apps/ contains anything that gets deployed. Each app has its own build, start, and dev scripts.
- packages/ contains code consumed by apps. Nothing here is deployed on its own.
- tooling/ separates dev tooling from runtime code, which keeps imports clean and intentions clear.
Step by Step: Setting It Up
1. Initialize the Repository
mkdir my-monorepo && cd my-monorepo
pnpm init
git init
2. Configure pnpm Workspaces
Create pnpm-workspace.yaml at the root:
packages:
- "apps/*"
- "packages/*"
- "tooling/*"
3. Add the Root package.json
{
"name": "my-monorepo",
"private": true,
"packageManager": "pnpm@9.12.0",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"clean": "turbo run clean && rm -rf node_modules"
},
"devDependencies": {
"turbo": "^2.3.0",
"typescript": "^5.6.0"
},
"engines": {
"node": ">=20"
}
}
4. Configure Turborepo
Create turbo.json:
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"clean": {
"cache": false
}
}
}
5. Set Up the Base TypeScript Config
At the root, create tsconfig.base.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true
}
}
6. Create a Shared Package
Inside packages/shared/package.json:
{
"name": "@repo/shared",
"version": "0.0.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src",
"build": "tsc"
},
"dependencies": {
"zod": "^3.23.8"
}
}
Then in packages/shared/src/index.ts:
import { z } from "zod";
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1),
});
export type User = z.infer<typeof UserSchema>;
7. Use Shared Code From Apps
In apps/api/package.json, add:
"dependencies": {
"@repo/shared": "workspace:*",
"fastify": "^5.0.0"
}
Now you can import the schema and type from anywhere:
import { UserSchema, type User } from "@repo/shared";
That single import keeps your frontend and backend speaking the exact same language.
Tooling Packages: One Source of Truth
The tooling/ folder is what separates a hobby repo from a maintainable one. Here is what to put inside.
Shared ESLint Config
tooling/eslint-config/package.json:
{
"name": "@repo/eslint-config",
"version": "0.0.0",
"private": true,
"main": "index.js"
}
tooling/eslint-config/index.js:
module.exports = {
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
rules: {
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }]
}
};
Shared TSConfig Presets
tooling/tsconfig/base.json, nextjs.json, and node.json let each app extend the right preset:
{
"extends": "@repo/tsconfig/node.json",
"include": ["src"]
}
Common Mistakes to Avoid
- Mixing apps and packages. Keep them separate so your dependency graph stays predictable.
- Publishing internal packages. Always set
"private": trueunless you really publish to npm. - Forgetting the workspace protocol. Use
"workspace:*"instead of version numbers for internal packages. - Building shared packages unnecessarily. If you point
mainandtypesat the source files, TypeScript handles everything during dev with no compile step. - Ignoring Turborepo task dependencies. The
^buildsyntax ensures dependencies build before consumers.
Scaling the Structure
As the project grows, you can extend this layout without rewriting it.
- Add a apps/mobile folder for an Expo or React Native app sharing types from
@repo/shared. - Add a packages/email for transactional templates with React Email.
- Add a packages/auth wrapping your auth provider so frontend and backend share session helpers.
- Enable Turborepo Remote Cache on Vercel or self hosted to share build cache across the team and CI.
CI Pipeline Example
A minimal GitHub Actions workflow at .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm build
- run: pnpm test
Final Thoughts
A clean monorepo full stack project structure is not about following a trend. It is about removing friction so your team can ship features instead of fighting tooling. Start with the layout above, keep the boundaries between apps, packages, and tooling sharp, and let Turborepo and pnpm do the heavy lifting. You will feel the difference the first time you change a shared type and watch every consumer update in the same pull request.
FAQ
Should I use Turborepo or Nx for a full stack JavaScript project?
If your stack is mostly JavaScript or TypeScript and you want minimal configuration, Turborepo is the simpler choice. Nx shines for very large teams that need code generators, dependency graphs, and tighter framework integration out of the box.
Can I keep my Python or Go backend in the same monorepo?
Yes. Put it under apps/api with its own build scripts. Turborepo can still orchestrate tasks even if the language is not JavaScript, as long as you define the right build and dev commands in the package.json or use a wrapper script.
Do I need to build shared packages before running dev?
Not if you point main and types directly at the TypeScript source. This works perfectly for internal packages and avoids unnecessary watch processes during development.
How do I deploy individual apps from a monorepo?
Most platforms like Vercel, Netlify, and Railway have native monorepo support. You set the root directory to apps/web or apps/api and the install command to pnpm install. Turborepo will build only the changed app and its dependencies.
Is a monorepo overkill for a small project?
Not really. The setup cost is low and the benefits show up the moment you add a second app or want to share types between frontend and backend. Starting with this structure saves you a painful migration later.

