The first serious monorepo problem is rarely where the code lives. It is what happens after a small change: unrelated tests run, several teams wait for the same pipeline, and nobody is quite sure who should review the shared package that broke the build.
These problems tend to emerge gradually. A repository that works well for 12 engineers can become an obstacle at 120, even though its directory structure has barely changed.
Choosing between Nx, Turborepo, and Bazel matters, but the build tool is only one part of TypeScript monorepo architecture. The other is organizational: package boundaries, ownership, review authority, and the relationship between the dependency graph and the teams working inside it. No orchestrator can compensate for weak decisions in those areas.
A monorepo is a dependency model, not a folder layout
"All the code in one repository" is too broad to be a useful definition. It can describe several fundamentally different setups:
A monolith contains one deployable application with a shared build and release process.
A polyrepo separates applications, services, and libraries into different repositories, usually with independent CI pipelines and versioning.
A monorepo contains multiple projects that can be built — and often deployed — independently while remaining in one repository.
The difference is not co-location. It is the project graph.
A workspace can contain 40 packages and still behave like an oversized application if it has no enforceable boundaries, affected-project logic, or clear ownership. A scalable monorepo needs workspace management, build orchestration, dependency rules, and a defined operating model.
Its purpose also changes as the company grows. For a small team, one repository offers practical conveniences: shared types, atomic commits, consistent dependencies, and one place to search. In a much larger organization, the repository becomes part of engineering governance. Its configuration determines which teams can change a package, which projects may depend on it, and how far a breaking change can travel.
Google and Microsoft have both documented their use of monorepos. Broader claims — such as a supposed majority of large companies using the model — are harder to verify and should not drive an architecture decision.
Monorepo and polyrepo move coordination costs to different places
Neither model removes complexity. Each decides where engineers encounter it.
A monorepo makes coordinated changes easier. If a shared library and three applications need to adopt a new contract together, the work can be reviewed and tested in one pull request. With separate repositories, the same update may require multiple releases, dependency upgrades, and a transition period in which consumers expect different versions of the contract.
A shared lockfile can also reduce dependency drift. Packages are less likely to end up on conflicting versions of TypeScript, React, or core development tools simply because their repositories evolved independently.
The trade-off is a larger potential blast radius. A broken shared package can block several teams. An indiscriminate dependency upgrade may affect every application. Deployment independence, which separate repositories provide by default, must be deliberately preserved inside a monorepo.
CI exposes this trade-off quickly. A basic pipeline that rebuilds and retests everything after each commit grows with the total repository rather than the change being reviewed. That can be acceptable with 20 packages and intolerable with 200.
Affected-project detection and caching are therefore part of the operating model, not optional finishing touches. Without them, a large monorepo becomes expensive to use.
A polyrepo is still a better fit when:
Product lines operate independently and share almost no code.
Acquired businesses retain different stacks and release processes.
Regulatory or security requirements demand stronger physical or access-level isolation.
Teams have genuinely separate delivery schedules and rarely need coordinated changes.
Combining unrelated systems in one repository does not create alignment. It introduces coupling without providing much in return.
Build the project structure before optimizing the build
Orchestration tools can accelerate a well-designed workspace. They cannot create meaningful boundaries where none exist.
Separate applications from reusable packages
Many TypeScript monorepos use two top-level directories:
apps/ for web applications, APIs, background workers, and other deployable units.
packages/ for UI components, domain logic, configuration, generated clients, and reusable libraries.
The names are conventional. The dependency rule behind them is what matters.
Applications may depend on packages, and packages may depend on other packages. Shared packages should not import implementation details from an application. If imports can flow freely in both directions, the directory split is only cosmetic.
A package also needs a reason to exist. Breaking every small utility into its own project can create more configuration and graph traversal without establishing a useful boundary. Packages should normally represent a reusable capability, a domain, a deployment concern, or an ownership unit.
Use TypeScript Project References before compilation becomes monolithic
A single tsconfig.json that covers the entire repository eventually turns the codebase into one large TypeScript program. The compiler and editor must process that program even when a developer is changing one isolated package.
TypeScript Project References, introduced in TypeScript 3.0, divide the workspace into smaller composite projects. Each project can be built and type-checked separately while remaining part of a larger reference graph.
Referenced projects emit declaration files that consumers can use instead of repeatedly analysing the full source. The tsc --build command traverses the graph, identifies outdated projects, and compiles them in dependency order.
This is useful outside CI as well. Microsoft's TypeScript performance guidance recommends dividing large codebases into smaller projects partly because it can reduce editor memory usage. Autocomplete, navigation, and type checking remain more responsive when the language service does not treat the whole repository as one program.
Project References do add maintenance. The reference graph must match actual package dependencies, and composite projects need stable output settings. But that cost is easier to control than a compilation unit that becomes slower regardless of which code changed.
Make module boundaries executable
Directories communicate intent; they do not enforce it.
Without automated checks, convenient imports accumulate. A payments package reaches into an authentication package's internals. An admin application imports code directly from the customer application. Shared libraries begin depending on product-specific logic.
Each shortcut may look harmless in isolation. Together, they create a dense dependency graph that is difficult to refactor and almost impossible to test selectively.
Nx module-boundary rules, for example, let teams tag projects with labels such as scope:client, scope:admin, and scope:shared. Configuration then defines which groups may depend on one another. A prohibited import fails during linting instead of becoming a matter of opinion in code review.
This is primarily a governance feature. It turns a decision such as "payments must not depend on authentication internals" into a rule applied consistently to every contribution.
Turborepo vs. Nx vs. Bazel
Turborepo, Nx, and Bazel can all prevent redundant work, but they operate at different levels of scope and complexity. Package count alone is a weak selection criterion. Team structure, language mix, CI behaviour, and governance requirements usually reveal more.
Turborepo: task orchestration without a large framework
Turborepo is usually the least disruptive option for an existing JavaScript or TypeScript workspace. It works with package scripts, understands relationships between tasks, and supports local and remote caching.
That makes it a practical choice when the main problem is repetitive CI work. A team can improve build performance without replacing its package manager or reorganizing the repository around a broader project framework.
The narrower scope is deliberate. Turborepo orchestrates and caches tasks, but it does not provide the same built-in project-governance model as Nx.
Nx: a project graph with governance controls
Nx maintains a detailed graph of projects and their dependencies. It uses that graph to calculate affected projects and also provides generators, plugins, dependency visualization, and enforceable module boundaries.
Those features become more useful when several teams share the same repository. At that point, the organization may need consistent project scaffolding, visible dependency relationships, and rules preventing domains from importing one another's internals.
For a workspace with only a few packages, Nx can introduce more machinery than the team needs. Its benefits become clearer once the repository contains multiple domains, deployment units, and owners.
Bazel: a different build model for polyglot repositories
Bazel is a hermetic, language-independent build system designed for very large codebases. Its strengths include precise input tracking, reproducible builds, visibility controls, caching, and remote execution.
Adopting it is a larger architectural decision. Teams must describe builds using Bazel's model and maintain BUILD files rather than simply orchestrating existing package scripts.
Most TypeScript-first organizations do not need that cost. Bazel becomes relevant when TypeScript is only one part of a large polyglot repository and a JavaScript-oriented orchestrator can no longer represent the entire build graph reliably.
Dimension
Turborepo
Nx
Bazel
Lowest; works with existing scripts
Moderate; requires plugins, generators, and configuration
Highest; introduces a new build model and BUILD files
Local and remote task caching
Local and remote caching through Nx Cloud or self-hosted options
Local caching and remote execution
First-class project graph and affected commands
Focused on JavaScript and TypeScript
JavaScript and TypeScript first, with plugins for other languages
Designed for polyglot repositories
Project tags and lint-enforced dependency rules
Visibility rules in BUILD definitions
Small to mid-sized, single-language repositories
Mid-sized and large multi-team workspaces
Very large polyglot organizations
Thresholds such as "move to Bazel after 100 packages" should be treated as rough heuristics. A small but tightly coupled workspace may need graph-aware tooling earlier than a larger repository made up of mostly independent packages.
Before choosing, ask:
How many projects and teams share the workspace?
How long does an ordinary pull request spend in CI?
Do teams need enforceable domain boundaries?
Will a substantial part of the repository use languages outside the JavaScript ecosystem?
Turborepo is a reasonable default when a TypeScript workspace mainly needs faster task execution. Nx fits multi-team environments where project governance matters. Bazel warrants consideration when polyglot scale and reproducibility justify its operational cost.
Looking at how established best TypeScript development companies organize large workspaces may provide useful reference points. The choice should still be based on measured constraints in the repository being designed.
Ownership should follow the dependency graph
Separate repositories create obvious ownership boundaries. A monorepo removes those walls, so the organization must recreate them through policy and automation.
Every domain package needs an identifiable owner. A CODEOWNERS file can assign directories to teams and require their approval before changes are merged. The payments team then reviews modifications to the payments domain regardless of who submitted the pull request.
Ownership rules and dependency rules solve different problems:
Module boundaries determine which projects may depend on one another.
CODEOWNERS determines who must review changes to a project.
A contribution can respect the dependency direction and still modify a sensitive package without the responsible team knowing. Both controls are needed.
Team Topologies provides a useful model for dividing responsibility. Stream-aligned teams can own the applications and domain packages required to deliver a customer outcome. A platform team can own CI infrastructure, generators, workspace configuration, and common build tooling.
The platform team should remain as thin as the organization allows. If routine product work repeatedly requires its intervention, the repository has replaced local team autonomy with a central bottleneck.
The alternative is shared mutable territory: many teams contribute to common packages, but nobody has the authority or time to refactor them, manage compatibility, or decide what belongs there. Maintenance then falls to whichever team last touched the code.
This also becomes a staffing issue. A company may need to hire senior TypeScript developers who have maintained Nx or Turborepo workspaces under real multi-team pressure. Creating the initial configuration is rarely the hardest part. The challenge is preserving useful boundaries as the product, repository, and organization change.
Decide how dependencies and package versions will behave
Internal packages need an explicit way to declare workspace dependencies.
The pnpm workspace protocol, through specifications such as workspace:* and workspace:^, is a practical default. It forces an internal dependency to resolve from the local workspace instead of silently using a registry version. If the package is later published, pnpm converts the workspace reference into a normal semantic-version range.
Third-party dependencies require a separate policy.
Using one version across the repository reduces duplication and keeps projects aligned. The cost is coordination: upgrading a core library may require several applications to change together. Allowing each package to choose its own version gives teams more independence, but it can gradually recreate the dependency drift that consolidation was meant to prevent.
For core dependencies, a single-version policy is usually easier to maintain. Temporary exceptions can still make sense during migrations or when applications have incompatible runtime requirements.
Published internal packages also need a release process. Changesets records release intent in small files added during development, then aggregates those records into version changes and changelogs. It supports both lockstep and independent versioning, so adopting it does not force every package into the same release policy.
Dependency drift rarely arrives as one major failure. One package delays an upgrade, another pins a workaround, and a third moves ahead. Months later, a security update or framework migration must account for several incompatible paths. A default version policy, automated updates, and documented exceptions make that accumulation visible.
Keep CI proportional to the size of the change
Slow monorepo pipelines usually indicate that the system is repeating work or invalidating too much of the graph. The fixes should be introduced in sequence.
Start with remote caching
Local caching benefits only the machine that has already completed a task. Remote caching allows CI jobs and developer machines to reuse outputs produced elsewhere when the declared inputs match.
Turborepo describes remote caching in straightforward terms: the aim is to avoid performing the same work on multiple machines. If CI has already built a package from identical inputs, another job can retrieve the output instead of compiling it again.
Cache quality depends on accurate task definitions. Missing inputs can produce incorrect reuse. Inputs defined too broadly cause harmless changes to invalidate the cache. A consistently low hit rate often means the system does not describe task dependencies precisely enough.
Run tasks only for affected projects
A graph-aware tool can compare a commit or branch against the dependency graph and identify the projects affected directly or transitively.
Changing a leaf package should not trigger builds for 150 unrelated projects. Affected-project commands keep validation time tied to the scope of the change. In a large workspace, that difference determines whether developers receive feedback in minutes or begin avoiding local checks because they take too long.
Distribute work only after reducing it
Some changes genuinely affect a large part of the repository. Teams may also require a complete test suite on the main branch regardless of the affected graph. Distributed execution can spread independent tasks across several workers and reduce elapsed time.
It also costs more and adds operational complexity. Caching and affected-project selection should work correctly before the team adds machines. Otherwise, the pipeline merely performs redundant work faster.
Warning signs that a repository has outgrown basic scripts include:
CI time rises while the average change remains similar in size.
Teams wait for tests belonging to unrelated projects.
Small edits invalidate most cached tasks.
The affected set is routinely large because package dependencies have become dense.
More parallel shell scripts will not fix those conditions. They usually point to missing graph awareness, inaccurate cache inputs, or boundaries that no longer isolate domains.
Vendor benchmarks often report dramatic improvements from caching. The figures may be accurate for the repositories tested, but they are not portable guarantees. Results depend on task duration, dependency density, cache hit rate, and the frequency with which relevant inputs change.
How large monorepos fail
Most failures are not caused by repository size alone. They appear when the dependency graph, CI model, and ownership structure stop supporting one another.
Hidden coupling makes affected builds ineffective
Unrestricted imports create cross-domain dependencies and circular references. The graph becomes so connected that a small change affects most of the repository.
This is not only a code-design problem. It removes much of the performance benefit of graph-aware builds because the affected set can no longer stay small.
Shared CI blocks unrelated teams
One flaky required check or unstable shared package can prevent every team from merging. The cost of that interruption rises with the number of engineers using the repository.
Project-specific validation, reliable required checks, clear package ownership, and careful control of shared dependencies reduce the risk. They cannot eliminate it entirely. Shared delivery infrastructure is one of the costs the monorepo model accepts.
Git operations eventually reach infrastructure limits
Most TypeScript organizations will never approach the scale of the Windows repository described in Microsoft's account of the largest Git repository. It contained roughly 3.5 million files, occupied about 300 GB, and supported thousands of engineers making thousands of pushes each day. Some routine Git commands reportedly took between 30 minutes and several hours.
That experience led to GVFS and later Scalar, which introduced specialized techniques for operating enormous repositories.
The case is not an argument against a typical TypeScript monorepo. It shows that the model requires dedicated infrastructure investment at the far end of the scale.
Repository consolidation loses useful history
A migration that ignores Git history usually produces one of two poor outcomes: a repository containing every historical blob regardless of value, or a clean import that loses useful blame and audit information.
The team should decide before consolidation whether to preserve, selectively graft, or separately archive history. Organizations that depend heavily on git blame, compliance records, or incident investigation may accept a larger repository. Others may prefer a clean start while retaining the original repositories as read-only archives.
Motivated and focused experts for up to 60% less than locals, delivered in days, not months.
Migrate to a governed monorepo in stages
Moving from polyrepo — or cleaning up a repository that already contains many loosely organized projects — does not require a big-bang rewrite. Each stage can address one class of problem.
Phase 1: Consolidate the selected projects
Move the relevant projects into one repository and configure pnpm, Yarn, or npm workspaces. Establish a predictable installation process and, where appropriate, a shared lockfile.
Avoid combining consolidation with a large architectural refactor. The immediate objective is to make the projects coexist and build consistently.
This is also the point to decide what happens to Git history. Changing that decision after the migration is much harder.
Phase 2: Assign boundaries and owners
Map the domains before the shared workspace makes accidental coupling easier. Add dependency rules, associate packages with teams, and configure CODEOWNERS.
The first set of rules will probably expose dependencies that conflict with the intended architecture. Those conflicts are migration work. Weakening the rules until the existing code passes only preserves the structure the migration was supposed to improve.
Phase 3: Add orchestration and caching
Once the dependency graph represents the real architecture, introduce affected-project detection and remote caching.
CI should then begin scaling with the size of the change rather than the total repository. This is usually the stage where developers notice the clearest day-to-day improvement.
Phase 4: Reassess the build system as the organization changes
Tooling should respond to observed constraints.
A TypeScript-only workspace may start with Turborepo. Nx may become worthwhile once several teams need stronger workspace governance. Bazel belongs in the discussion only when the repository develops into a genuinely large polyglot system that needs hermetic cross-language builds and remote execution.
The progression matters more than the orchestrator's name. Teams comparing best TypeScript development companies should assess potential partners on migration planning, CI design, package ownership, and boundary enforcement — not merely whether they have configured Nx before.
How Devico approaches TypeScript monorepo projects
A useful engagement does not begin with a predetermined preference for Nx or Turborepo. It begins with the current system: the dependency graph, build times, package ownership, deployment requirements, and expected team growth.
The work may include consolidating repositories, separating deployable applications from reusable packages, introducing Project References, enforcing import boundaries, or redesigning CI around affected projects and remote caching. The order depends on which constraint is causing the most disruption.
Dedicated teams and staff augmentation can both fit this kind of work because monorepo architecture is not complete after the initial configuration. Teams must maintain boundaries, own shared tooling, and continue migration work without putting product delivery on hold. Engineers joining the project need to operate within that model rather than configure an orchestrator and leave.
The build system should usually be selected after the organization has clarified ownership and dependency boundaries. A repository scales when its project graph broadly reflects how teams are expected to work. If the code structure contradicts the organization, faster builds only delay the underlying problem.
Frequently asked questions
Is a monorepo suitable for a small startup?
Yes, when projects share code or frequently change together. A small team can benefit from common types, atomic commits, consistent dependencies, and one installation process.
It does not need enterprise governance on day one. Workspaces may be sufficient initially. Boundary rules, extensive ownership controls, and distributed execution can be added when package or team growth creates a concrete need.
Does a TypeScript monorepo make builds slower?
A naive implementation does. Rebuilding every project after every change becomes increasingly expensive as the repository grows.
Project References, affected-project commands, and remote caching change the cost model. When configured correctly, most builds remain proportional to the affected part of the dependency graph rather than the entire workspace.
Can a TypeScript-first monorepo include other languages?
Yes. Turborepo and Nx can orchestrate Go, Python, Rust, and other tasks when those tasks are exposed through the workspace's build commands.
That approach works while TypeScript remains the repository's centre of gravity. If non-JavaScript systems make up a substantial share of the codebase and require hermetic cross-language builds, Bazel provides a stronger model at a considerably higher adoption cost.
What if applications need different versions of the same dependency?
Workspace tools can resolve different dependency versions for individual packages, and Changesets can release internal packages independently.
The team should still establish whether the divergence is necessary. Multiple versions of core dependencies increase the number of combinations that must be tested and make future upgrades harder. A shared version is usually the better default, with exceptions reserved for real compatibility or migration constraints.
Motivated and focused experts for up to 60% less than locals, delivered in days, not months.