React Monorepo With Turborepo: Shared Components, Configs, Packages
Set up a React monorepo with Turborepo — share UI components, ESLint configs, TypeScript settings, and design tokens across every app without duplicating code.
Why Monorepos Actually Make Sense Now
At some point you realize you've copy-pasted the same Button component into four different repos, and now you have four slightly-different versions of it. That's the monorepo problem in a nutshell — not a tooling problem, a coordination problem. Turborepo, released as v1.0 back in 2021 and now at v2.x, finally makes the tooling side boring enough that you can focus on the coordination part.
The pitch is simple: one repository, multiple apps and packages, with a build pipeline smart enough to only rebuild what changed. If your packages/ui library hasn't changed since last Tuesday, Turborepo skips it entirely and pulls the output from cache. On a team of five, that's the difference between a 4-minute CI run and a 12-second one.
Honestly, the reason people avoided monorepos for years wasn't philosophy — it was that tools like Lerna were painful to configure and even more painful to debug. Turborepo fixes the DX layer. It's just a turbo.json and some workspace globs. The rest is normal Node/pnpm territory you already know.
Worth noting: you don't need to migrate everything at once. A common pattern is starting with a single shared packages/ui that your two main apps consume, then expanding. You don't need to boil the ocean on day one.
Scaffold the Repo Structure
Create the top-level repo with pnpm workspaces — it's the most monorepo-native package manager right now, and Turborepo's docs assume it. Your directory layout will look like this:
my-monorepo/
├── apps/
│ ├── web/ # Next.js marketing site
│ └── dashboard/ # Vite + React internal app
├── packages/
│ ├── ui/ # Shared React components
│ ├── config-ts/ # Shared tsconfig files
│ └── config-eslint/ # Shared ESLint configs
├── package.json
├── pnpm-workspace.yaml
└── turbo.jsonYour root pnpm-workspace.yaml is literally two lines:
packages:
- 'apps/*'
- 'packages/*'Then install Turborepo at the root: pnpm add -D turbo -w. That -w flag installs to the workspace root. After that, every app and package is its own package.json with its own dependencies. Quick aside: don't hoist everything to the root by default — let each workspace own its deps. It makes dependency graphs explicit and prevents the classic "it works on my machine" CI failures.
Writing turbo.json: The Build Pipeline
This is where Turborepo earns its keep. The turbo.json at your repo root defines the task dependency graph — which tasks depend on which other tasks, what outputs to cache, and what environment variables invalidate that cache.
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**", "!.next/cache/**"]
},
"typecheck": {
"dependsOn": ["^build"]
},
"lint": {},
"dev": {
"cache": false,
"persistent": true
}
}
}The ^build syntax is the key concept. The caret means "run build in all packages this workspace depends on first." So when you run turbo build in apps/web, Turborepo knows to build packages/ui before it builds the app. No manual ordering required.
The outputs field tells Turborepo what to cache. Anything matching .next/** or dist/** gets stored — locally in node_modules/.cache/turbo and optionally in Turborepo's remote cache. On repeat runs, if inputs haven't changed, the cached output is restored in milliseconds.
In practice, the dev task is the one you'll run most during local development: turbo dev. With persistent: true and cache: false, all your app dev servers start in parallel and hot-reload independently. Running turbo dev --filter=web scopes it to just the web app and its upstream dependencies — useful when you only care about one app.
Building the Shared UI Package
This is the payoff. Your packages/ui is a regular React library with its own package.json, its own TypeScript config, and a barrel index.ts. Every app in your monorepo installs it as a workspace dependency.
// packages/ui/package.json
{
"name": "@repo/ui",
"version": "0.0.1",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"peerDependencies": {
"react": ">=18"
}
}Notice main points directly to the TypeScript source. That's an internal-packages pattern — since all consumers live in the same repo and share the same TypeScript compilation, you skip the transpile step entirely for local development. The app's own bundler (Next.js, Vite) handles compilation. It's faster than building a distribution and re-importing it.
Your packages/ui/src/index.ts just re-exports everything:
``ts
export { Button } from './components/Button';
export { Card } from './components/Card';
export { Modal } from './components/Modal';
// etc.
`
Then in apps/web/package.json, declare it as a dependency:
`json
{
"dependencies": {
"@repo/ui": "workspace:*"
}
}
`
And import it just like any npm package: import { Button } from '@repo/ui'. That's it. The workspace protocol (workspace:*) tells pnpm to symlink to the local package instead of downloading from the registry. If you're building a component library that needs visual documentation alongside this, [Storybook](/blog/storybook-component-library) integrates cleanly as a third app in the apps/` directory.
One more thing — if you need Tailwind in your shared UI package, you've got two options. Either configure each app to include the package's source in its content glob (./node_modules/@repo/ui/src/**/*.{ts,tsx}), or use CSS variables for your design tokens and keep the Tailwind config per-app. The CSS variable approach scales better when apps have different themes.
Sharing TypeScript and ESLint Configs
Copy-pasting tsconfig.json across five workspaces is how you end up with five different strict settings and four different target values. The fix is a packages/config-ts package that exports reusable base configs.
// packages/config-ts/base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"isolatedModules": true
}
}
```
Each app extends it:
```json
// apps/web/tsconfig.json
{
"extends": "@repo/config-ts/nextjs.json",
"include": ["src", "next-env.d.ts"],
"exclude": ["node_modules"]
}Same idea for ESLint. Create packages/config-eslint/index.js that exports your org's base config — React rules, import ordering, TypeScript plugin settings — and have each app's .eslintrc.js extend it with ['@repo/config-eslint']. One place to update a rule, all apps pick it up on the next turbo lint run.
Look, this isn't glamorous work. But standardizing configs across a team of eight developers saves you from the "why does the dashboard allow any but the web app doesn't?" conversation every sprint. Pair this with a well-structured design system documentation approach and you've got the foundation for real consistency at scale.
Remote Caching and CI Integration
Local caching is great. Remote caching is where Turborepo goes from "nice tool" to "actually transformative." With remote cache enabled, when CI runs turbo build on a PR, it checks a shared cache (Vercel's remote cache, or self-hosted) before doing any work. If another PR already built the same packages with the same inputs, it restores from cache in seconds.
Set it up with three commands:
``bash
# Authenticate with Vercel's remote cache
npx turbo login
npx turbo link
# Or use a self-hosted cache endpoint
# Set TURBO_API, TURBO_TOKEN, TURBO_TEAM in your CI env
`
In your CI pipeline (GitHub Actions, etc.):
`yaml
- name: Build
run: pnpm turbo build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
``
That's the entire CI change. Turborepo handles the cache hit/miss logic automatically. A realistic outcome: a full repo build that takes 8 minutes drops to under 60 seconds on cache hits. Not theoretical — that's what you'll see on a mid-sized monorepo with 3-4 apps sharing 2-3 packages.
Worth noting: remote cache artifacts include environment variable hashes, so two builds with different NODE_ENV values are cached separately. You won't accidentally serve a dev build in production because both happened to share source inputs. That kind of correctness-by-default is rare in build tooling.
For filtering in CI, run turbo build --filter='[HEAD^1]' to only build packages that changed relative to the previous commit. Combined with remote cache, this turns your monorepo's CI into something that only does the work that genuinely matters for a given PR.
Monorepo Patterns Worth Stealing
A few patterns that show up in well-run monorepos that you won't find in the official docs. First: the packages/tokens package. Instead of defining your color scale, spacing values, and shadow presets in each app separately, export them from a single package as both TypeScript constants and CSS custom properties. Every app imports the same source of truth. This pairs really well with how Empire UI structures its design tokens — if you're pulling components from the library, you can map your token package to match Empire UI's variable names and get zero-conflict theming across all your apps.
Second: versioned internal packages. For most internal monorepos, workspace:* (always use local) is fine. But if you're publishing some of your packages to npm (say, your open-source UI library alongside private apps), pin to exact workspace versions and use Changesets to manage releases. Don't mix workspace:* with semver in the same package — it gets weird fast.
Third: task filtering in scripts. Add per-app scripts to your root package.json for the most common operations:
``json
{
"scripts": {
"dev:web": "turbo dev --filter=web",
"dev:dashboard": "turbo dev --filter=dashboard",
"build:all": "turbo build",
"lint": "turbo lint",
"typecheck": "turbo typecheck"
}
}
`
This way, new team members don't need to learn Turborepo's filter syntax on day one — they just run pnpm dev:web`.
Finally: don't go package-crazy. The temptation after setting up a monorepo is to create a package for everything — packages/utils, packages/hooks, packages/types. Resist it early. Start with packages/ui, add packages/config-ts and packages/config-eslint, and let genuine duplication drive new packages. Over-fragmented monorepos are just as annoying to navigate as the copy-paste chaos you were trying to escape. If you want to audit your component architecture before splitting things out, the advanced Tailwind config patterns can inform how you scope your design system packages.
FAQ
No — pnpm or yarn workspaces alone can link packages together. Turborepo adds the task pipeline and caching layer on top. For small repos with one or two apps, plain workspaces are often enough.
pnpm is the strongest choice in 2026 — it's fastest, has the strictest dependency isolation, and Turborepo's official examples use it. npm workspaces work but are slower on install. Yarn v1 is legacy; avoid it for new monorepos.
Don't add Tailwind as a dependency of the package itself. Instead, tell each consuming app to scan the package's source files in its content glob. Tailwind runs at the app level and picks up all class names from your shared components automatically.
Vercel's remote cache is free for personal accounts and most small teams. Self-hosting is also free — Turborepo's remote cache protocol is open, and you can point it at any S3-compatible storage using environment variables.