Monorepo Architecture for Frontend Teams
A monorepo is not a folder with all your code in it. It is a set of conventions about boundaries. Done right, teams move faster. Done wrong, every build is a CI nightmare.
Article focus
3x faster CI
With proper caching and boundaries
Key takeaways
- Package boundaries are the architecture of a monorepo. Each package owns its types, tests, and dependencies.
- Build caching (Turborepo, Nx) is not optional. Without it, monorepo CI times grow linearly with the codebase.
- Shared configs (TypeScript, ESLint, Prettier) should be a package, not a root file. Version them explicitly.
When does a monorepo make sense?
When you have multiple packages that share types, configs, or tooling - a frontend app, a backend, a shared types library, a design system.
Monorepos are trendy, but they are not free. They require discipline around package boundaries, build caching, and dependency management. If you have a single app with no shared packages, a monorepo adds complexity without benefit.
The inflection point is when you have two or more packages that share code. A Next.js frontend and an Express backend that share TypeScript types (see API Contracts). A design system package and the apps that consume it. A set of utility packages used across multiple projects.
At that point, a monorepo with clear boundaries and efficient caching is significantly faster than publishing and versioning separate packages for every shared piece of code. It is one of the five pillars in the Frontend Architecture framework.
Package Dependency Graph Explorer
Click each package to see what it depends on. Root-level packages have no internal deps.
Shared Types
packages/sharedNo internal dependencies - root-level package.
Click each package to see its internal dependencies. Leaves with no deps are root-level.
Package boundaries - the real architecture
Each package is a self-contained unit with its own tsconfig, its own dependencies, and an explicit public API through its index file.
The most common monorepo mistake is treating packages like folders. A package has a boundary. Its internal modules are private. Its exports (what goes through the index.ts barrel) are its public API. Everything else can be refactored without affecting consumers.
I follow three rules for package structure:
typescript
// packages/shared/src/index.ts - explicit public API
export { UserSchema } from './schemas/user';
export { formatCurrency } from './utils/format-currency';
export type { User, Product } from './types';
// Everything else is internal
// ./schemas/product.ts - not exported, can be refactored freely
// ./utils/format-date.ts - not exported, not public API- Each package has its own tsconfig.json with strict mode enabled. No global TypeScript config.
- Each package declares its own dependencies explicitly. No hoisting surprises.
- Each package exports a minimal surface area through its index file. If it is not exported there, it is private.
Package Boundary Visualizer
Toggle files between public (exported) and private. See how consumers are affected by what you expose.
packages/shared/src/
Click any file to toggle its export status
Consumer Impact
apps/webimport { ... } from '@company/shared/schemas/user'import { ... } from '@company/shared/utils/format-currency'import { ... } from '@company/shared/types/index'import { ... } from '@company/shared/schemas/product'import { ... } from '@company/shared/utils/format-date'apps/apiimport { ... } from '@company/shared/schemas/user'import { ... } from '@company/shared/utils/format-currency'import { ... } from '@company/shared/types/index'import { ... } from '@company/shared/schemas/product'import { ... } from '@company/shared/utils/format-date'Private imports break consumers. The barrel protects internals.
Config Inheritance Explorer
Click each config file to see how per-package overrides work. Inherited vs overridden vs new properties are color-coded.
packages/configs/tsconfig/base.jsonBuild caching is not optional
Without caching, CI time grows with every package you add. Turborepo or Nx caches outputs by content hash and skips unchanged packages.
The naive monorepo CI runs all the tests for all the packages on every commit. If you have 10 packages, CI takes 10 times as long as a single-package repo. This does not scale.
Turborepo (turbo) and Nx solve this by tracking the dependency graph and caching outputs. If a package has not changed, it reuses the cached output. If only one package changed, only that package and its dependents need to run.
With caching enabled, a CI run where only the web app changed takes seconds - lint and typecheck the web package, build it from cache if unchanged, and deploy. The other 9 packages are cache hits.
json
// turbo.json - minimal caching configuration
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": []
},
"lint": {
"outputs": []
},
"typecheck": {
"dependsOn": ["^build"],
"outputs": []
}
}
}Build Caching Timeline
Select which package changed and toggle caching on/off to see the impact on build time.
Dependency management - the silent killer
Use the package manager's workspace feature. Pin versions. Run deduplication regularly. Do not let hoisting surprises leak.
Dependency management in monorepos is harder than in single-package repos because of hoisting. If two packages depend on different versions of the same library, the package manager hoists the shared version to the root node_modules. This can cause hard-to-debug runtime errors where the wrong version is resolved.
Practical rules:
- Use pnpm or Yarn Berry with strict dependency resolution. Avoid npm - its hoisting algorithm is too permissive.
- Pin dependency versions across packages. Do not let one package use React 18 and another use React 19.
- Run deduplication as a CI step. If two versions of the same package exist in the lockfile, the CI should fail.
- Use the "packageManager" field in the root package.json to enforce the package manager version across the team.
Hoisting Problem Simulator
Toggle between npm and pnpm to see how different package managers resolve conflicting dependency versions.
Web App
package.json"react": "18.3.1"
Admin Panel
package.json"react": "19.0.0"
node_modules structure
CI strategy for monorepos
Run tasks only for changed packages and their dependents. Use Turborepo's --filter or Nx's affected commands.
The CI pipeline is where monorepos either shine or die. A well-configured CI runs the minimal set of tasks for each commit.
The key insight: do not hardcode which packages to test. Let the build system figure it out from the dependency graph. When package A changes, turbo runs A's tests, then checks if anything depends on A and runs those tests too.
yaml
# .github/workflows/ci.yml
name: CI
on: [pull_request]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
- run: pnpm install --frozen-lockfile
- run: pnpm turbo typecheck lint test build
# turbo runs only affected packages + dependentsCI Task Pipeline Builder
Select a scenario to see which CI tasks run and how long the pipeline takes. Parallel execution is visualized.
Only the web app changed. 6 tasks run in parallel where possible.
Need help implementing this?
I build these systems for a living - let's work on yours.
Frontend Architecture & System Design
Structure so teams can ship. Clear boundaries, state strategy, and contracts. New features land cleanly; refactors stay low-risk.
Frontend Development
Production UIs in React, Next.js, or Vue- third-party and payment integrations included. Built for real traffic and maintained in production.
Full Stack Development
End-to-end apps: MERN, React+Node, or MEVN. Auth, role-based access, and real-time where it matters. Designed to scale without rewrites.
Your turn
- >Did this help you ship something?
- >Which part clicked the most for you?
- >Applying this at work? Share your experience.
Recommended blogs
Continue reading

The Ultimate Guide to Free AI API Keys: 6 Platforms You Need to Know
Free AI API keys from OpenRouter, Groq, Google AI Studio, NVIDIA Build, GitHub Models, and Cloudflare Workers AI - rate limits, working provider combos, and key hygiene explained.
Photo from Pexels
Read article
JavaScript Closures Explained: Why Your Functions Remember Everything
Learn JavaScript closures with interactive demos. Covers lexical scope, the var vs let loop bug, stale React hooks, memory leak patterns, and closure interview questions.
Reference photo by Asad Photo on Pexels
Read article
Discussion
Leave a comment
Thoughts, questions, corrections - all welcome.