Skip to main content

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 fromCannot import from
packages/app@myorg/ui-components, @myorg/hooks
@myorg/ui-componentsreact, external libs
@myorg/hooksreact, @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?

  1. Create a monorepo structure.
  2. Import package code into packages/.
  3. Update imports to use @myorg/package-name.
  4. Keep old repos as read-only for 6 months (for Git history).
  5. 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.

Further Reading