A TypeScript monorepo can make shared code easier to manage, but only if the setup stays predictable as the codebase grows. This guide gives you a reusable checklist for building a pnpm-based TypeScript monorepo with project references and shared types, plus the practical details that usually cause friction: package boundaries, tsconfig layering, build order, editor behavior, and the mistakes that turn a tidy workspace into a slow or confusing one.
Overview
If you want one repository for multiple TypeScript packages, the goal is not simply to “make imports work.” A good monorepo setup should help you do four things consistently:
- Share code without copying it between apps and services
- Build packages in the right order
- Keep type-checking fast enough to use every day
- Preserve clear package boundaries so each part of the workspace can evolve safely
For many teams, a practical baseline is:
- pnpm workspaces for dependency and package management
- TypeScript project references for multi-package type-checking and incremental builds
- A shared base tsconfig for compiler consistency
- One or more shared packages for types, utilities, or domain models
This approach works well for common combinations such as:
- A React frontend and Node backend in one repo
- Multiple services that share API contracts
- A package library plus example apps
- An internal platform repo with tools, schemas, and application packages
A simple folder structure usually looks like this:
repo/
apps/
web/
api/
packages/
shared-types/
ui/
config/
package.json
pnpm-workspace.yaml
tsconfig.base.json
tsconfig.jsonThe most important architectural decision is to treat each workspace package as a real package, not just a convenient folder. That means each package should have:
- Its own
package.json - Its own
tsconfig.json - A clear public entry point
- Explicit dependencies
That discipline is what keeps a typescript monorepo maintainable after the first few weeks.
If you are still standardizing base compiler options, keep a separate reference handy for tsconfig.json best settings by project type. For path alias details beyond monorepos, see the dedicated TypeScript path aliases guide.
Checklist by scenario
Use this section as the part you come back to before creating or refactoring a workspace. The right setup is less about complexity and more about choosing only the pieces your repo actually needs.
Scenario 1: New pnpm TypeScript monorepo from scratch
If you are starting fresh, use this checklist:
- Create a root workspace definition.
Add apnpm-workspace.yamlthat includes your package folders, for exampleapps/*andpackages/*. - Create a root package.json for shared scripts.
Use the root for orchestration scripts such as type-checking, linting, testing, and builds. Avoid placing application runtime logic here. - Add a base tsconfig.
Createtsconfig.base.jsonfor shared compiler options likestrict,noUncheckedIndexedAccess, module settings, and target settings. Keep this file focused on defaults that genuinely apply across packages. - Add a root tsconfig for references.
Your roottsconfig.jsoncan be a thin coordinator with afilesarray andreferencesto packages. This is the entry point fortsc -bbuilds. - Give each package its own tsconfig.json.
Each package should extend the base config and set package-specific options such asrootDir,outDir,composite, and declaration output behavior. - Enable project references where build order matters.
Ifapps/webdepends onpackages/shared-types, add a TypeScript reference from the app package to the shared package. - Decide what should be built.
Not every package needs emitted JavaScript. A pure type package may only need declarations depending on your tooling. Be explicit rather than inheriting accidental defaults. - Use workspace package dependencies.
Declare internal dependencies in each package’spackage.jsoninstead of relying on root-level resolution magic. Your imports should work because the package is declared, not because the repository happens to contain a matching folder. - Standardize package entry points.
Prefer one clear public API, such assrc/index.ts. This reduces deep imports and makes refactoring easier. - Add root scripts you will actually use.
Typical scripts:typecheck,build,lint,test, andclean. A simple setup is usually more durable than a highly abstract one.
Scenario 2: Shared types between frontend and backend
This is one of the most common reasons to adopt a shared types monorepo structure. The safest version is also the simplest: create a dedicated package for contracts that both sides import.
Checklist:
- Create a dedicated shared package.
Name it something obvious, such as@repo/shared-typesor@repo/contracts. - Separate pure types from runtime code when useful.
If the package only contains interfaces, type aliases, and utility types, keep it minimal. If it also contains schemas or helper functions, be clear about runtime implications. - Export from a stable index file.
Do not make consumers import from scattered internal paths. - Keep transport contracts distinct from database shapes.
A DTO shared between client and server is not automatically the same as your ORM model or persistence entity. - Use runtime validation when trust boundaries exist.
TypeScript types do not validate network input at runtime. If you share API contracts, pair them with a validation strategy. For comparison of common options, see Zod vs io-ts vs Valibot vs Yup for TypeScript runtime validation. - Document ownership.
Someone should own changes to shared contracts so they do not drift into a miscellaneous dumping ground.
If the shared package is specifically for API responses, combine this setup with patterns from How to Type API Responses in TypeScript Without Lying to the Compiler.
Scenario 3: Existing JavaScript repo migrating to TypeScript
If your workspace already exists and you are adding TypeScript gradually, avoid a full rewrite. Use a staged migration.
- Convert one package at a time.
Start with a shared utility or a leaf package rather than your largest app. - Add base config before strict package-level refinement.
You want consistency first, then tighter rules. - Use references only where they provide value.
You do not need to convert every folder into a referenced TypeScript project on day one. - Prefer clear package boundaries over broad path alias shortcuts.
Aliases can help, but they can also hide dependency mistakes if used carelessly. - Turn on stricter options in steps.
A gradual path tends to be more successful than enabling every strict rule at once in a mature JS codebase.
Scenario 4: React app plus Node API in one repository
This is a common typescript workspace setup because both sides benefit from shared contracts and shared tooling.
- Keep app-specific tsconfig files separate.
A browser app and a Node app usually need different lib settings, module resolution details, and environment types. - Share only what is truly shared.
Domain types, API contracts, validation schemas, and utility functions are common candidates. Framework-specific code is usually not. - Avoid leaking server-only types into the browser package.
Node globals and server library types can create confusing frontend errors. - Set up separate linting and test assumptions where needed.
The repo can share a foundation while allowing app-level differences. - Keep build output isolated per package.
Do not emit all compiled files into one shared directory.
For package-specific app setup, use dedicated guides for React + TypeScript setup and Node.js + TypeScript setup.
Scenario 5: Internal package library in one repo
If your monorepo exists mainly to host reusable packages, focus on publishable boundaries even if you never publish them publicly.
- Treat every package like a versioned artifact.
- Generate declarations consistently.
- Keep package dependencies explicit and minimal.
- Do not let example apps import unpublished internal file paths.
- Test package consumption from the public entry point only.
This reduces surprises later if a package needs to be split out or consumed by external tooling.
What to double-check
These are the details most likely to cause subtle bugs or slow builds in a pnpm TypeScript monorepo.
1. Are project references actually wired correctly?
Project references only help if each referenced package is configured as a buildable TypeScript project. In practice, double-check:
compositeis enabled where required- References point to the package tsconfig, not an arbitrary file path
- You are building with
tsc -bwhen you expect reference-aware behavior - Generated output is not checked into source folders by mistake
If references feel confusing, that is usually a sign your package boundaries are not yet clean enough, not that TypeScript itself is the whole problem.
2. Are you mixing path aliases and package imports inconsistently?
One of the fastest ways to create confusion is to import some internal modules via package names and others via aliases that bypass package boundaries. Pick a rule and document it.
A useful baseline is:
- Use real package imports for cross-package access
- Use path aliases only inside a package if they improve local ergonomics
This keeps dependency graphs easier to reason about. If alias resolution breaks in tooling, the dedicated path aliases guide is the right next reference.
3. Are shared types drifting away from runtime truth?
Shared TypeScript types are helpful, but they can become misleading if they describe data you never validate. This matters most for:
- HTTP requests and responses
- Environment variables
- Database records after transformation
- Third-party API payloads
Use types for editor and compiler help, but validate data at boundaries. That principle matters more in a monorepo because shared packages can make incorrect assumptions spread very quickly.
4. Are package outputs and inputs clearly separated?
Each package should have a reliable distinction between source and build artifacts. Double-check:
srccontains authored codedistor another output folder contains build output- Imports do not point into another package’s build directory
- Editor search and test tooling are not indexing stale output by accident
5. Are environment types scoped correctly?
Browser packages, Node packages, test packages, and shared packages often need different ambient types. If one package suddenly reports missing globals or unexpected globals, review:
libtypes- test runner type packages
- framework-generated declaration files
If you run into missing symbols, see How to Fix “Cannot find name” and Other Missing Type Errors in TypeScript.
6. Is linting aligned with the workspace shape?
Monorepos often outgrow one copied ESLint file. If linting behaves differently between packages, standardize the root approach and allow package overrides only where justified. For a modern baseline, see ESLint + TypeScript Flat Config Guide.
Common mistakes
The following mistakes show up often in otherwise reasonable TypeScript monorepo setups.
Using the monorepo as an excuse to avoid package design
Putting many folders in one repository does not automatically create a good architecture. If packages have blurry responsibilities, the workspace will feel harder over time, not easier.
Creating one giant shared package for everything
A package called shared often becomes a catch-all for unrelated code. Prefer smaller, purpose-driven packages such as shared-types, config, ui, or utils. This reduces accidental coupling.
Deep-importing internal files across packages
Imports like @repo/shared-types/src/internal/foo defeat package boundaries. They make refactors risky and often break builds or publishing workflows later.
Assuming TypeScript references replace all build tooling decisions
TypeScript project references help with type-checking and incremental builds, but they do not eliminate decisions about runtime module format, bundling, tests, or framework-specific behavior. They are one piece of the tooling picture.
Over-sharing app-specific code
If code exists only because one framework or runtime needs it, it probably does not belong in a broad shared package. Keep framework adapters close to the app that uses them.
Letting root config become a dumping ground
The root is for shared defaults and orchestration, not every possible override. When the root tsconfig or root package scripts try to solve every edge case, package-level clarity gets lost.
Ignoring error messages that point to dependency graph problems
Repeated TypeScript build errors in a monorepo are often signs of architecture drift: circular dependencies, hidden ambient types, incompatible module assumptions, or packages reaching into one another in unsupported ways. If you need a broader troubleshooting reference, keep TypeScript error codes list: meaning, common causes, and fixes nearby.
Confusing interfaces, types, and contracts in shared packages
When a contracts package grows, naming and consistency matter. If the team keeps debating interface versus type for shared public APIs, standardize the rule and document it. This article can pair well with Interface vs Type in TypeScript: Current Best Practices.
When to revisit
A good monorepo setup is not “finished.” It should be reviewed when the cost of change starts to rise or when the underlying tools and workflows shift. Use this practical checklist during planning cycles or after notable tooling changes.
Revisit your setup when:
- You add a new app or service.
Check whether current package boundaries still make sense and whether new shared code belongs in an existing package or a new one. - You change frameworks or build tools.
A new frontend framework, test runner, or Node runtime assumption can affect module settings, emitted output, and type environments. - Type-checking becomes noticeably slower.
Review project references, package size, generated files, and whether one shared package has become too broad. - Shared types change frequently and break consumers.
That may indicate weak contract ownership, poor API stability, or the need to split packages more carefully. - Developers start bypassing package imports.
Deep imports and ad hoc aliases are usually a signal that package APIs are not ergonomic enough. - Your CI and local behavior diverge.
If builds pass locally but fail in CI, or the reverse, revisit workspace scripts, package boundaries, and environment-specific configuration.
A practical review routine
Before a new quarter, major refactor, or tooling migration, run this short review:
- List all workspace packages and their responsibilities in one document.
- Identify which packages are true libraries, which are apps, and which are config-only helpers.
- Check each package for a clear public entry point and explicit dependencies.
- Review tsconfig inheritance and remove package-level overrides that no longer serve a purpose.
- Run a full type-check and build from the root using the same commands CI uses.
- Look for places where runtime validation is missing at external boundaries.
- Document the import rules: package imports, path aliases, and what is considered private.
If you do only one thing after reading this guide, do this: make every package in your monorepo understandable on its own. When a package has a clear purpose, a clean entry point, and an explicit dependency list, pnpm workspaces and TypeScript references become much easier to maintain. That is the difference between a monorepo that helps your team and one that slowly turns into a collection of hidden assumptions.