TypeScript project references can make large codebases easier to build, reason about, and scale, but only when they are set up with clear boundaries and realistic expectations. This guide gives you a reusable checklist for deciding when to use project references, how to configure composite projects, how to run tsc --build effectively, and what to review when builds become slower or more fragile after architecture changes.
Overview
If you work in a single small app, TypeScript project references may feel optional. In a larger frontend, backend, shared-library, or monorepo setup, they often become a practical tool for keeping builds predictable.
At a high level, project references let one TypeScript project depend on another in an explicit way. Instead of treating your entire repository as one giant compilation unit, you split it into smaller projects with their own tsconfig.json files. TypeScript can then understand the dependency graph and build those projects in the correct order.
The main benefit is not magic speed by itself. The real benefit is controlled compilation. Once you define project boundaries well, TypeScript can reuse work more effectively, skip rebuilding unaffected projects more often, and avoid rechecking unrelated code.
This matters most when:
- your editor feels slow in a large workspace
- CI builds spend too much time type-checking everything
- shared packages force broad rebuilds
- you need a clearer contract between app code and internal libraries
- your repository has grown beyond what one root
tsconfighandles comfortably
There are a few concepts to keep straight:
- Project references connect one TypeScript project to another using the
referencesfield. composite: trueis required on referenced projects. It tells TypeScript that the project participates in build mode and must follow stricter rules.tsc --buildortsc -bbuilds referenced projects in dependency order.- Incremental output helps TypeScript avoid repeating work between builds.
If you are still comparing the role of plain tsc against other tooling, it helps to review TypeScript Build Tools Compared: tsup vs esbuild vs swc vs tsc. Project references solve a build-graph problem, while bundlers and transpilers solve different parts of the pipeline.
A useful mental model is this: project references are most valuable when your codebase has real internal packages or modules with stable boundaries. They are less helpful when used only as an optimization trick on top of a codebase that still behaves like one tightly coupled app.
Checklist by scenario
Use this section as a practical decision list. You do not need every item, but the closer your setup is to these patterns, the more likely project references will help.
Scenario 1: A growing monorepo with shared packages
This is the most natural fit for TypeScript project references.
- Create one
tsconfig.jsonper package or one package-level config plus a shared base config. - Set
composite: truein every package that is referenced by another package. - Set
declaration: truewhen packages expose types to consumers. - Use a root build config whose main purpose is to list
references, not compile all source files directly. - Keep package boundaries aligned with actual ownership or reuse, not arbitrary folders.
- Prefer imports through package entry points over deep relative imports into another package's internals.
A minimal package config often looks like this:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"references": [
{ "path": "../shared" }
]
}At the root, a build-focused config may look like this:
{
"files": [],
"references": [
{ "path": "packages/shared" },
{ "path": "packages/api" },
{ "path": "packages/web" }
]
}If you are building this structure from scratch, see TypeScript Monorepo Setup Guide: pnpm, Project References, and Shared Types for broader repo organization.
Scenario 2: One large app with clear internal modules
You do not need a monorepo to benefit. A single repository can still use references if it contains separable modules such as:
- a domain or shared-types layer
- a server package and a client package
- a design system package and app package
- generated API types separated from consuming code
Checklist:
- Split code only where there is a stable API boundary.
- Avoid creating many tiny referenced projects just to imitate package architecture.
- Use one top-level app project that references a small number of internal libraries.
- Make sure each subproject can describe its own include, output, and dependencies clearly.
This is often the right middle ground for teams that are not ready for a full workspace or package-manager monorepo structure but still need faster TypeScript builds and less editor churn.
Scenario 3: Backend services with shared contracts
Node.js codebases often gain value from project references when multiple services or workers share internal types, validation schemas, or transport contracts.
- Place shared contracts in their own project.
- Keep runtime-only utilities separate from pure type or schema packages when possible.
- Build downstream services from the shared contract package outward.
- Be consistent about module resolution and path style across all referenced projects.
If module imports become unreliable after the split, review How to Fix TypeScript Module Resolution Errors. Many failed project-reference rollouts are actually import-resolution problems in disguise.
Scenario 4: React or Next.js apps with shared UI and types
Project references can help frontend teams when shared UI components, utilities, or typed API clients start slowing down the main app build.
- Put shared UI, shared config, and shared types into separate projects only if they are reused enough to justify the boundary.
- Make sure JSX settings and module options are compatible across the projects that interact.
- Do not assume framework build tools fully replace TypeScript build mode; they serve different purposes.
- Keep framework-specific code from leaking into otherwise generic shared packages unless that coupling is intentional.
For framework-specific typing practices, you may also want Next.js + TypeScript Guide: App Router Patterns That Stay Type-Safe.
Scenario 5: You want faster builds, but your codebase is still tightly coupled
This is the scenario where teams often expect too much from project references.
Before introducing them, ask:
- Can you identify stable boundaries with minimal circular dependencies?
- Do teams already think in terms of packages or modules?
- Can you stop cross-importing implementation details?
- Will each project have a meaningful output and owner?
If the answer is mostly no, start by improving boundaries first. Project references amplify good architecture; they do not create it.
What to double-check
Once a basic setup works, these are the details most worth revisiting before you commit to it long term.
1. Your referenced projects actually need composite: true
This setting is required for referenced projects. It also makes TypeScript stricter about configuration, which is useful because it forces clearer build definitions. A project with vague includes or mismatched root/output settings often breaks once composite is enabled, and that is usually a sign that the config needed cleanup anyway.
2. Build configs and editor configs may need different roles
Many teams benefit from separating concerns:
- a base config for shared compiler options
- package configs for local project behavior
- a root build config that lists references
- possibly a separate editor-focused config for convenience in some setups
Trying to make one tsconfig.json do everything can make the system harder to reason about.
3. Output directories are isolated
Each project should write to its own output directory. Shared output folders invite stale artifacts, accidental overwrites, and confusing declaration files. If builds seem inconsistent, old output is often part of the problem.
4. Imports follow the declared dependency graph
If project A does not reference project B, A should not import B. This sounds obvious, but it is a common source of drift in large repos. Deep relative imports can bypass intended boundaries and make the build graph less trustworthy.
5. You understand what is being emitted
Project references often work best when declaration output is part of the design. Be intentional about:
- whether a project emits JavaScript
- whether it emits declarations only
- where declaration maps go
- how consumers resolve those outputs
For example, a pure types package may reasonably emit declarations, while an internal library may emit both JavaScript and types.
6. Incremental behavior is helping, not hiding problems
Incremental builds are useful, but they can mask stale-state issues during local development. When debugging strange behavior, a clean rebuild is still worth trying. If the clean rebuild fixes everything, inspect your outputs, references, and dependency boundaries more closely.
7. Path aliases do not conflict with package boundaries
Path aliases can make imports nicer, but they also make it easier to blur lines between projects. If aliases point into another project's source folder directly, you may undercut the point of project references. Favor aliases that reflect public entry points.
8. Shared types are not carrying hidden runtime assumptions
A common anti-pattern is putting runtime helpers, environment-specific code, and domain types into one shared package. It looks convenient at first, but it couples many consumers to one broad dependency. Keep shared contracts narrow where you can.
This is especially important for API shapes. If you need a refresher on keeping those definitions honest, see How to Type API Responses in TypeScript Without Lying to the Compiler.
Common mistakes
The following issues come up repeatedly when teams adopt TypeScript project references for faster builds.
Using too many tiny projects
More boundaries are not automatically better. Each project adds config, outputs, references, and maintenance cost. Split by ownership, deployment unit, or durable API surface, not by every folder that seems reusable.
Keeping circular dependencies in place
Project references work best with an acyclic dependency graph. If packages depend on each other in both directions, build order becomes awkward and the architecture is telling you something important. Extract the shared contract or move the boundary.
Mixing source imports with built-output expectations
Some setups import directly from source, while others expect built declarations and compiled output. Trouble starts when the repository does both without a clear rule. Decide what local development and CI should resolve against.
Forgetting that references improve structure, not just speed
Teams sometimes evaluate the change only by stopwatch. Speed matters, but the larger win is often architectural clarity: fewer accidental imports, better package ownership, and more predictable builds. If those things do not improve, the split may not be designed well.
Assuming framework tooling will configure everything correctly
Frameworks can abstract part of the build process, but TypeScript still needs a coherent project graph. If the framework compiles but editors, tests, or tsc -b behave differently, your TypeScript config likely needs to be more explicit.
Ignoring duplicate or conflicting type declarations
After splitting projects, declaration files and ambient types can overlap in new ways. If you start seeing duplicate symbol issues, review generated outputs, included files, and dependency duplication. The troubleshooting steps in How to Fix Duplicate Identifier Errors in TypeScript are often relevant here.
Using references before resolving basic type hygiene problems
If a codebase is full of broad any usage, unsafe assertions, and inconsistent public types, project references will not fix that. They may make those issues easier to isolate, but the underlying type quality still needs work.
When to revisit
Project references are not a one-time setup. They are worth revisiting whenever the shape of the codebase changes.
Come back to this checklist in these situations:
- before quarterly or seasonal planning when teams may reorganize packages
- after introducing a new framework, bundler, or test runner
- when CI build time starts creeping up
- when editor responsiveness gets worse in large workspaces
- after adding a major shared library or generated types package
- when ownership boundaries change between teams
- after repeated module resolution or declaration-file issues
A practical review can be short. Ask these seven questions:
- Do our current projects still map to real boundaries?
- Are there any circular dependencies between projects?
- Are we importing across boundaries through stable entry points?
- Do output folders and declaration files stay clean and isolated?
- Does
tsc -breflect the same dependency graph our team expects? - Have new tools introduced conflicting assumptions about module resolution or transpilation?
- Are we measuring the right outcome: reliable builds, not just one local speed test?
If you need an action plan, use this one:
- List current projects and their dependencies.
- Remove accidental cross-imports and deep imports first.
- Enable or verify
composite: trueon referenced projects. - Create a root build config that only orchestrates references.
- Run a clean
tsc -bbuild and verify outputs. - Test editor behavior, local development, and CI separately.
- Document the intended dependency rules so new packages follow the same pattern.
The best TypeScript project reference setup is usually not the most elaborate one. It is the one your team can understand six months later, after the repo grows, tools change, and build performance matters again. Treat project references as a maintenance tool for codebase boundaries, and the build-speed gains become much easier to preserve.