TL;DR:
After a long search and many disappointments (lerna, nx, turbo...) we have arrived at a typescript monorepo that we can count on and enjoy. Even though our setup is minimal, we successfully use in medium-large and complex projects. It seamlessly handles diverse technologies and tools, making it extremely powerful yet simple to use for sharing code across many projects.
Overview
Unlike other solutions that require constant maintenance, numerous dependencies, clis, custom commands and come with complex limitations and opinionated structures, our monorepo setup is really simple and requires almost no setup and no dependencies. No need for complex tsconfig and references, no need for complex monorepo tools such as NX or TurboRepo. Nothing. It just works, thanks to PNPM.
In this article, we will share our approach to splitting and organizing code projects into a modern, super-easy-to-use TypeScript monorepo. Our methodology revolves around using PNPM workspaces exclusively, which we’ve found to be a powerful tool for managing monorepos. We’ll also explain our distinction between libraries and apps and how we handle their compilation in a seamless and efficient way.
Understanding the Two Types of Projects: Libraries and Apps
Our monorepo distinguishes between two types of projects:
1. Apps
Apps are deployable packages. These include:
- Servers (e.g., Node.js services built with express, fastify, NestJS, socket.io etc )
- Frontend applications (built with Vite, Next.js, or other frameworks)
- Lambda functions or other serverless architectures
Apps are the only entities that get compiled in our repositories. Importantly, apps can also act as libraries—a shared app could expose its own API that other apps or libraries can consume.
2. Libraries
Libraries are shared pieces of code extracted into separate packages. They are reusable and serve as dependencies for both other libraries and apps. These are generally utility functions, shared types, components, or logic that other projects can consume.
In our solution, libraries are not compiled individually. Instead, they are used as raw source files and only get compiled when consumed by an app. This allows us to simplify the development workflow while keeping shared code in a modular and reusable format.
Note: While usually not compiled, we still want to hide this as an implementation details and not import from src directories.
import { x } from '@org/libname'; // GOOD! future proof and keep lib facade
import { y } from '@org/libname/src/z'; // BAD! breaks encapsulation
Note: Some achieve the ability to
import { x, y } from '@org/libname'
by avoiding thesrc
dir in the lib and/or by mappingpaths
intsconfig.json
.
We reject both these approaches since we want our code to be natural and with no config specific to a library or to the monorepo setup.It should just work by adding an dependency to the library in package.json and importing from that library like any other library. That's it.
The Trick: Specifying main
and types
in Library package.json
To make our setup work seamlessly, we use a small but effective trick in the package.json
of each library. Here's how:
{
"main": "src/index.ts",
"types": "src/index.ts"
}
Why This Approach Works
By setting src/index.ts
as both the main
and types
entry points in the library’s package.json
:
1. Libraries are not precompiled
Instead of building each library independently, we allow the consuming app to compile the libraries as part of its own build process. This ensures that libraries always use the same TypeScript settings and compiler version as the consuming app.
2. Encapsulation
Libraries are still required to be treated as packages.
They must be included as dependencies in the app's package.json
and imported using standard package import syntax:
import { x, y } from '@org/libname';
This prevents importing files directly from src/
, which can lead to fragile code and non-standard module resolution.
3. Seamless Switching Between Compiled and Non-Compiled Libraries
With this setup, we can easily replace a non-compiled library with a compiled version without changing the import paths or structure.
Example Structure
Here’s a simplified example of how a monorepo using this approach might be organized:
monorepo/
├── apps/
│ ├── server/
│ │ ├── src/
│ │ │ └── index.ts
│ │ │ └── utils.ts
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ └── README.md
│ ├── frontend/
├── libs/
│ ├── lib1/
│ │ ├── src/
│ │ │ └── index.ts
│ │ │ └── internals1.ts
│ │ │ └── internals12.ts
│ │ ├── package.json
│ ├── lib2/
│ │ ├── src/
│ │ │ └── index.ts
│ │ │ └── lib2-internals.ts
│ │ ├── package.json
│ ├── lib3/
│ ├── lib4/
│ ├── lib5/
├── package.json
├── pnpm-workspace.yaml
├── tsconfig.base.json
└── README.md
> Note: the tsconfig is only included in apps, not in libs.
# pnpm-workspace.yaml
packages:
- apps/*
- libs/*
A Closer Look at lib1/package.json
{
"name": "@org/lib1",
"version": "1.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"dependencies": {}
}
Consuming the Library in an App
In server
app, the library can be used as follows:
-
Add the library as a dependency in
server/package.json
:{ "dependencies": { "@org/lib1": "workspace:*" } }
-
Import and use the library in your code:
import { someUtilityFunction } from '@org/lib1'; const result = someUtilityFunction(); console.log(result);
Variation on Structure
Minimal, using packages
monorepo/
├── packages/
│ ├── lib-utils/
│ ├── lib-components/
│ ├── app-server/
│ └── app-frontend/
└── pnpm-workspace.yaml
# pnpm-workspace.yaml
packages:
- packages/*
Complex, multi-level deep
monorepo/
├── pnpm-workspace.yaml
├── client/
│ ├── apps/
│ │ ├── admin-site/
│ │ └── app-site/
│ │ └── landing-site/
│ ├── libs/
│ │ └── app-core/
│ │ └── shared-components/
│ │ └── design-system/
├── common/
│ └── libs/
│ └── models/
├── devops/
│ ├── apps/
│ │ ├── base-devops/
│ │ ├── infra-stack/
│ │ └── load-testing/
│ └── libs/
│ ├── cdk-helpers/
├── server/
│ ├── apps/
│ │ ├── admin-api/
│ │ ├── chat-server/
│ │ ├── external-api/
│ │ ├── jobs/
│ │ ├── messages-listeners/
│ │ ├── webhooks/
│ │ └── widget/
│ ├── libs/
│ │ ├── business-context/
│ │ ├── chats/
│ │ ├── core/
│ │ │ ├── auth0-api/
│ │ │ ├── aws/
│ │ │ ├── cache/
│ │ │ ├── fastify/
│ │ │ ├── logging/
│ │ │ ├── mongo/
│ │ │ └── oauth2/
│ │ └── authorizers/
# pnpm-workspace.yaml
packages:
- client/apps/*
- client/libs/*
- devops/apps/*
- devops/libs/*
- server/apps/*
- server/libs/*
- server/libs/*/*
- common/libs/*
Advantages of Our Approach
1. Single Compilation Point
Apps compile all dependencies, ensuring consistent TypeScript configuration and reducing build complexity.
2. Modularity
By enforcing package-based imports, we maintain a clean and modular codebase.
3. Simplified Development Workflow
Developers can work on libraries and apps without worrying about separate compilation steps for libraries.
4. Mixing Nesting Level
You can nest at any level and even have multiple nesting levels supported at the same folder.
4. It Just Works
Simple and seamless like any other lib: pnpm add @my/lib
and import {whatever} from '@my/lib'
No need for cli tools, propriety configuration, complex commands and processes.
5. Flexibility
The seamless switch between compiled and non-compiled libraries allows for easy transitions if we need to distribute compiled versions of libraries in the future.
6. Consistency
Using PNPM workspaces ensures all dependencies are managed centrally, reducing version mismatches and duplication.
Conclusion
Our TypeScript monorepo strategy provides a modern, efficient, and user-friendly development experience. By leveraging PNPM workspaces and a smart approach to library handling, we’ve created a setup that simplifies both development and deployment. Whether you’re working on shared libraries or deployable apps, this method ensures your codebase remains modular, maintainable, and easy to use.