Monorepo Structure for React Teams: Yarn Workspaces
As teams grow, a single React package becomes a bottleneck. Different teams own different features; coordinating releases is painful. A monorepo solves this: multiple packages (app, shared libraries, tools) live in one repository, share code, and ship independently. Yarn workspaces make this seamless: one node_modules, linked packages, single lock file, yet each package versions and deploys on its own schedule. This article shows you how to structure a monorepo for a team of 30+ engineers.
Monorepo vs. Polyrepo: When to Use Each
A monorepo is one repository with multiple packages. A polyrepo is many repositories, one per package.
Polyrepo advantages:
- Independent versioning and releases.
- Teams own separate repositories; no visibility into other teams' code.
- Smaller clones, faster builds for individual packages.
Polyrepo disadvantages:
- Sharing code requires publishing to npm (overhead).
- Coordinating breaking changes across packages is slow.
- Duplicated tooling and config per repo.
- Harder to refactor: code moves from Package A to Package B require bumping versions, publishing, and updating consumers.
Monorepo advantages:
- Shared code is simple: just import from
@myorg/shared. - Coordinating changes is instant: one commit touches multiple packages.
- Single test suite, single CI/CD, easier to refactor.
- Easier to onboard: clone once, see all code.
- Atomic commits: if auth changes break the main app, it's one commit to fix.
Monorepo disadvantages:
- Requires discipline: teams must respect package boundaries.
- Single lock file; dependency conflicts are visible immediately.
- Large repository size (though Git's shallow clone mitigates this).
- If you don't need sharing, it's overhead.
Use monorepo when:
- Multiple teams share code (auth library, UI components, utilities).
- You want atomic commits (auth change + app change = one commit).
- Your infrastructure supports it (Yarn, npm 7+, or pnpm).
- Team size is 10+.
Use polyrepo when:
- Packages are truly independent (no shared code).
- Teams have different release cycles (one publishes hourly, another quarterly).
- Team size is under 5.
Airbnb, Uber, Stripe, and Meta all use monorepos. They're the modern standard for teams shipping multiple products.
Monorepo Structure with Yarn Workspaces
Here's a proven structure for a React monorepo:
my-monorepo/
packages/
app/ # main React app
src/
package.json
vite.config.js
shared/ # shared libraries
ui-components/ # design system
src/
package.json
hooks/ # shared custom hooks
src/
package.json
utils/ # shared utilities
src/
package.json
tools/ # internal tooling
eslint-config/ # shared ESLint config
typescript-config/ # shared TypeScript config
build-tools/ # custom build scripts
docs/ # documentation site
src/
package.json
node_modules/ # single, deduplicated
package.json # root, defines workspaces
.yarn/ # Yarn cache
yarn.lock # single lock file
.eslintrc.json # root ESLint config, inherited by all
tsconfig.base.json # root TypeScript config, extended by all
CONTRIBUTING.md # shared guidelines
Root package.json
{
"name": "@myorg/monorepo",
"version": "1.0.0",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"dev": "yarn workspace @myorg/app dev",
"build": "yarn workspaces run build",
"test": "yarn workspaces run test",
"lint": "eslint packages --ext .js,.jsx,.ts,.tsx",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.0.0",
"eslint": "^8.0.0",
"typescript": "^5.0.0"
}
}
The workspaces field tells Yarn to treat packages/* as separate packages.
Individual package: app/package.json
{
"name": "@myorg/app",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest"
},
"dependencies": {
"@myorg/ui-components": "workspace:*",
"@myorg/hooks": "workspace:*",
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"vite": "^4.0.0"
}
}
The workspace:* prefix tells Yarn to use the local version, not npm. No version matching; always use the local version.
Individual package: shared/ui-components/package.json
{
"name": "@myorg/ui-components",
"version": "1.0.0",
"description": "Shared UI components for all products",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": "./dist/index.js"
},
"scripts": {
"build": "tsc && vite build",
"test": "vitest"
},
"devDependencies": {
"react": "^18.0.0",
"typescript": "^5.0.0"
}
}
Each package can have its own build script, dependencies, and version. Shared packages (ui-components, hooks) are built and exported.
Setting Up Shared Packages
Shared UI Components
packages/shared/ui-components/
src/
Button/
Button.tsx
Button.css
Button.stories.tsx
index.ts
Input/
Input.tsx
Input.css
Input.stories.tsx
index.ts
index.ts # barrel: export all
dist/ # compiled output
package.json
tsconfig.json
vite.config.js
.storybook/ # optional: Storybook config
// src/index.ts
export { Button } from './Button';
export { Input } from './Input';
// ...
Other packages import:
// packages/app/src/pages/Dashboard.tsx
import { Button, Input } from '@myorg/ui-components';
No versioning headaches: both the app and components are always synced. Refactor a component, update its callers, commit once. This is the monorepo superpower.
Shared Utilities
packages/shared/utils/
src/
formatters.ts
validators.ts
constants.ts
index.ts
package.json
// src/index.ts
export * from './formatters';
export * from './validators';
export * from './constants';
Shared Hooks
packages/shared/hooks/
src/
useAuth/
useAuth.ts
useAuth.test.ts
index.ts
useLocalStorage/
useLocalStorage.ts
useLocalStorage.test.ts
index.ts
index.ts
package.json
Package Boundaries and Import Rules
In a monorepo, enforce stricter boundaries than a single package:
| Can import from | Cannot import from |
|---|---|
packages/app | @myorg/ui-components, @myorg/hooks |
@myorg/ui-components | react, external libs |
@myorg/hooks | react, @myorg/utils, external libs |
Use ESLint:
// .eslintrc.json
{
"rules": {
"import/no-restricted-paths": [
"error",
{
"zones": [
{
"target": "./packages/shared/ui-components",
"from": "./packages/app",
"message": "ui-components cannot depend on app"
},
{
"target": "./packages/shared/hooks",
"from": "./packages/shared/ui-components",
"message": "hooks cannot depend on ui-components"
}
]
}
]
}
}
Building and Publishing
Development
# Install dependencies (Yarn links packages automatically)
yarn install
# Start the app
yarn workspace @myorg/app dev
# Run tests in all packages
yarn test
# Run tests in one package
yarn workspace @myorg/ui-components test
# Build one package
yarn workspace @myorg/ui-components build
# Build all packages
yarn build
During development, all packages are linked. Changes to @myorg/ui-components are immediately reflected in @myorg/app. No build step needed.
Publishing to npm
When you're ready to ship @myorg/ui-components, version and publish it:
# Update version in packages/shared/ui-components/package.json
# Commit
yarn workspace @myorg/ui-components build
yarn workspace @myorg/ui-components npm publish
# or use automated tooling like Changesets (recommended)
yarn changeset
yarn release
Using Changesets for Coordinated Releases
Changesets automate versioning and publishing across multiple packages:
yarn add -D @changesets/cli
# Create a changeset file
yarn changeset
# Which packages changed?
# What kind of change (major/minor/patch)?
# This creates a markdown file describing the change
# When ready to release:
yarn changeset version # bumps versions
yarn changeset publish # publishes to npm
Changesets are the standard for monorepo releases.
Scaling to Many Teams
As teams grow, each team owns one or more packages:
packages/
app/ # main team
shared/
ui-components/ # design systems team
utils/
hooks/
features/
auth/ # auth team
payments/ # payments team
analytics/ # analytics team
Each team:
- Owns their packages.
- Publishes their packages independently (or uses workspace:*).
- Updates shared packages when needed.
- Respects import boundaries.
With proper boundaries, teams can work independently, merge without conflict, and ship on different schedules.
Key Takeaways
- Monorepos scale to 100+ developers: share code, atomic commits, one lock file.
- Use Yarn workspaces:
workspace:*in package.json links packages without npm publishing. - Organize by package type: app (main product), shared (libraries), tools (internal).
- Enforce strict import boundaries: shared packages cannot depend on the app.
- Use Changesets for coordinated versioning and publishing.
- Start with one monorepo; split into polyrepo only if packages diverge completely.
Frequently Asked Questions
How do I clone a monorepo without downloading everything?
Use shallow cloning:
git clone --depth=1 https://github.com/myorg/monorepo.git
yarn install
Or use Git's new sparse checkout:
git clone --sparse https://github.com/myorg/monorepo.git
git sparse-checkout set packages/app
yarn install
Only the app and shared dependencies are cloned.
Can I have multiple apps in one monorepo?
Yes. Add both to packages/:
packages/
app-web/
app-mobile/
shared/
Each app is independent; they share the shared/ packages.
What if I want different Node versions per package?
Use .nvmrc in each package, or configure in CI/CD. Node version differences are rare in practice; keep it simple.
How do I migrate from polyrepo to monorepo?
- Create a monorepo structure.
- Import package code into
packages/. - Update imports to use
@myorg/package-name. - Keep old repos as read-only for 6 months (for Git history).
- Move on.
No rush; teams migrate gradually.
Does monorepo make deployment harder?
No. Deploy each package independently. The monorepo is just a development convenience. CI/CD detects which packages changed and builds/deploys only those.