About this article
This piece lays out a set of opinionated recommendations for working with monorepos via Nx. My motivation for writing this stems from my own early struggles with Nx—figuring out how to lay out a workspace was a major challenge, and I ran into several roadblocks. That said, after working with it for an extended period, I’m now confident that I’ve settled on a structure that works well for me.
A word about Nx
Nx functions as a lightweight wrapper around the Angular CLI, aiding in the organization of large-scale apps through a monorepo setup. In a monorepo, there’s a single Nx workspace housing multiple apps and libs (collectively referred to as Nx projects). Apps are deployable units, while libs are designed to contain reusable logic shared across the workspace. Nx is continuously improved and kept in sync with Angular by the team at Nrwl.
The value of Nx really shines when dealing with substantial Angular applications that share a lot of code, but it can also bring structure to smaller projects.
Here at StrongBrew, we’ve applied this technology for numerous clients. Although Nx comes with its own opinions, I’ve chosen to document the practices and principles I aim to follow.
The guidelines and rules presented in this article may suit your needs, but they’re not meant to be taken as the absolute gold standard. What counts as a best practice is often subjective. Still, I’d like to share my approach to building large Angular apps with Nx.
Barrel files
Barrel files play a crucial role in managing monorepos.
A barrel file is an index.ts file located in the src folder of each Nx lib, serving to expose specific functionality to the broader workspace.
This file becomes essential once you recognize a key hazard in monorepo code organization—the risk of exposing too many implementation details.
Since code sits in close proximity, it’s easy to use deeply nested relative imports and pull in pieces that the original developer didn’t intend for external use.
With the index.ts file in each Nx lib, you can define a clear public API—only symbols explicitly exported from here should be available for import elsewhere in the workspace.
Consider a @strongbrew/users lib that exports a UserService…
The barrel file for @strongbrew/users could look something like this.
// libs/users/src/index.ts
export * from './lib/services/user.service';
While it might appear obvious at first glance, there are several important practices worth reviewing…
Never import a library via a relative path
When UserService needs to be used in another app or library, the import should originate from @strongbrew/users. This approach is significantly tidier than referencing a relative path such as ../../../users/lib/src/index.ts, and it shields us from the overexposure issue mentioned earlier. Additionally, Nx comes with a built-in lint rule that enforces proper library API usage and prevents deep imports.
To resolve these module names to the appropriate barrel files, Nx relies on TypeScript’s path mapping mechanism.
"paths": {
"@strongbrew/users": {
"libs/users/src/index.ts"
}
}
Only 1 barrel file per lib
Barrel files can certainly grow large, but they provide a single, centralized location for managing all exports and significantly lower the likelihood of circular reference issues. For this reason, each lib is restricted to exactly one barrel file.
Never let a lib import from its own Barrel file
Modules inside a lib shouldn't be aware of what public API that lib exposes. As such, they must avoid referencing their own barrel file entirely.
When a module pulls something in from its own barrel, circular reference errors are almost inevitable. To prevent this, all internal cross-module imports should use relative paths.
Structuring the workspace
Nx already guides us toward a certain way of doing things, which is a plus. However, the question of how to lay out the workspace itself remains. For example, browsing a libs folder with 100 different libs isn't a very practical workflow…
Structuring apps
An app should be an empty shell
Apps act as deployable units, assembling the various pieces of the application. These apps are essentially empty shells that rely on libs to form the full application. Consequently, an app holds almost no logic and typically depends on lazy loading to pull in feature libs. Some of those feature libs might even be treated as microfrontends. Still, apps are rarely completely bare. They usually include:
- The overall layout (built from 'ui-kit' components)
- Routing setup
Keep the apps directory as flat as possible
It's unlikely that a monorepo will ever house more than 100 apps, and even in that scenario, categorizing them would probably not be practical.
Apps should not import from other apps
This might sound obvious, but it deserves to be stated: any logic that's meant to be reused belongs in libs, never in apps. Apps are meant for specific deployment targets only.
Structuring libs
Now for the opinionated part — let's explore how we can organize the libs in the workspace.
Here's what the directory layout of this workspace may look like:
appslibsfeatureapifoo- …
lazybar- …
sharedbaz- …
ui-kitutils
- A feature includes logic that pertains to a specific domain, such as user management or authentication.
- Meanwhile, the
utilslib holds logic that isn't tied to any domain at all, e.g., HTTP interceptors, shared RxJS operators, or a notification service. Think of it as the workspace's toolbox.
In the sections ahead, we'll dive into each of the 3 feature lib types, along with the ui-kit and utils libs.
feature/api
This folder houses Nx libs that serve a distinct, narrow purpose:
- They handle api logic or business logic intended for reuse.
- They define the types for REST responses, which we'll refer to as domain types.
- If there are models or DTOs in play, they belong here as well.
A dedicated api lib becomes especially valuable in a microservices setup. Each microservice would get its own api lib, usable across the whole monorepo.
Another common scenario is feature libs needing domain types from one another. Moving those types into api libs resolves that issue, allowing them to be reused anywhere in the monorepo.
feature/lazy
All feature libs suited for lazy loading go in this directory. To enable lazy loading, each lib must expose an NgModule from its barrel file and be imported in this manner:
RouterModule.forRoot([
{
path: 'users',
loadChildren: '@strongbrew/feature/lazy/users'
}
])
Being able to load these modules lazily or preload them upfront is one benefit. However, the real win is their complete isolation—they share no code with the rest of the workspace. So their barrel file only exports the NgModule and nothing else.
Lazy loaded modules must not share logic with the rest of the workspace
Should a lazyloaded module need to expose something, we move that logic into a dedicated feature/shared or feature/api library.
Whenever a feature/lazy module has to make XHR calls, it should hand them off to a feature/api library. Therefore, feature/lazy libraries are not allowed to contain any API logic.
With a state management solution such as ngrx/store, feature/lazy libraries would bring their own reducers and register them on the store instance via store.forFeature(). This mechanism produces lazy-loaded reducers.
feature/shared
There are features that cannot be lazyloaded, for instance when their logic has to be used by other parts of the codebase. Such cases call for an Nx library placed in the feature/shared directory.
When a feature/shared module makes XHR requests, it should pass that work to a feature/api library. So the feature/lazy library must stay free of API logic.
ui-kit
This library holds the presentational components used across different applications—dropdowns, datepickers, blank modals, and the like. A component such as user-detail does NOT fit in here for example. Multiple ui-kit libraries can coexist in a monorepo, and we should name them by their role; for instance, ui-kit-mobile is a typical label.
The ui-kit module includes an ngModule so that its components and directives can be declared and exported. Its barrel file typically exposes only the ngModule, as that acts as the exporting vessel.
That said, a ui-kit might also make certain types public in its barrel file—like DatepickerConfiguration or other types specific to that ui-kit.
utils
Various utilities live here: shared interceptors, guards, services, and custom RxJS operators. Think of it as a framework toolbox that any application could use. For tree-shaking reasons, we avoid introducing an ngModule in this library.
A utils library has no components, although having pipes or directives might force us to add an ngModule.
In smaller workspaces a single utils library can be enough, but splitting it into several ones may become necessary once it grows too large.
Following the split, the workspace directory layout could resemble this:
appslibsfeatureapifoo- …
lazybar- …
sharedbaz- …
ui-kitutilsrxjs-operatorsformshttp
Keep in mind that the forms lib is not meant to store forms or their configuration—rather, it holds generic form logic reusable across the entire workspace.
Prefixing libs
Ensuring unique selector names for components and directives makes prefixing essential in a monorepo. Since each project in angular.json carries a prefix property, we can assign a distinct prefix to every project.
For instance, if we require a feature/shared lib named messages, we could generate it with ng g lib messages --prefix sh-mes. Here, sh-mes becomes the prefix, so a message component created in this lib would use the selector sh-mes-message.
Linting and tags
When managing a monorepo, it’s vital to identify, classify, and execute commands based on the dependency graph—this is a critical aspect.
Nx automatically constructs the dependency graph for us, deriving it through static analysis of our TypeScript imports and exports, along with other Angular CLI-specific considerations.
It cannot auto-classify the graph since that relies on our subjective judgment, but it does offer utilities to simplify the process.
Nx allows adding tags to various libs and apps, and applying tslint rules to restrict imports—preventing issues like circular dependencies or broken lazy loading.
Tags are configured in the nx.json file at the root. They can be organized in different ways—some prefer per-team tags, others per-domain.
Personally, I prefer tags for each lib type. It’s subjective, but it works well for me (of course, it’s a matter of preference).
We establish 5 tag categories:
app: Assigned to all applicationsshared: Applied touikitandutilslibsfeature:lazy: Assigned tofeature/lazylibsfeature:shared: Applied tofeature/sharedlibsfeature:api: Assigned tofeature/apilibs
The rules can be uniform across all future workspaces in the project:
- A project tagged
tagmay only rely on others carrying eithersharedorfeature:sharedtags. - Any project marked
sharedis restricted to depending solely on fellowsharedprojects—no domain-specific code should leak in, after all. - Projects categorized as
feature:lazycan reference onlyshared,feature:shared, orfeature:apitagged projects. - A
feature:sharedproject may depend exclusively on projects taggedsharedorfeature:api. - Finally,
feature:apiprojects are allowed to depend only on others of the samefeature:apitag or onsharedones—loadingfeature:sharedinto an api library is clearly something we want to avoid.
Setting up tslint
Enforcing these boundaries requires employing the nx-enforce-module-boundaries tslint rule. Should the above constraints suit you, simply paste those exact module boundary definitions straight into the root-level tslint.json file.
"nx-enforce-module-boundaries": [
true,
{
"allow": [],
"depConstraints": [
{
"sourceTag": "app",
"onlyDependOnLibsWithTags": ["shared", "feature:shared"]
},
{
"sourceTag": "shared",
"onlyDependOnLibsWithTags": ["shared"]
},
{
"sourceTag": "feature:lazy",
"onlyDependOnLibsWithTags": [
"shared",
"feature:shared",
"feature:api"
]
},
{
"sourceTag": "feature:api",
"onlyDependOnLibsWithTags": ["feature:api", "shared"]
},
{
"sourceTag": "feature:shared",
"onlyDependOnLibsWithTags": ["shared", "feature:api"]
}
]
}
]
The tslint configuration above makes the previously mentioned rules mandatory.
Is this structure the only way?
Not necessarily—this layout works well for a monorepo containing five applications. However, when considering an organisation-wide monorepo, grouping features per application might be more suitable. The resulting structure could look like this:
appslibsapp1apifoo- …
lazybar- …
sharedbaz- …
app2apifoo- …
lazybar- …
sharedbaz- …
ui-kitutilsrxjs-operatorsformshttp
How to share code organisation wide?
While an organisation-wide monorepo brings considerable advantages, valid reasons might still exist to avoid it, spanning technical, cultural, legal, or other considerations.
Scenario A
Suppose our company runs ten actively maintained Angular projects with substantial code reuse, alongside five legacy projects lacking budget for upgrades to current Angular versions. Those legacy projects might also contain some Vue.js or React code. Managing such complexity within a single large workspace might bring more hassle than benefit. A dedicated workspace for the non-legacy Angular projects would be a practical alternative, remaining open to new projects later.
Scenario B
Our company delivers custom software to individual clients, each demanding its own design and extensive business logic, yet we still aim to avoid rebuilding common functionality every time.
A solution here is to set up an Nx workspace per client, complemented by a shared toolkit containing reusable logic. That toolkit resides in its own monorepo and gets published as an Angular package.
Conclusion
Hopefully, this has been informative. Workspace organisation is entirely our own choice, and we should adopt an approach that suits our needs, rather than following any blog post without consideration ;-). If this structure doesn’t fit your context, that’s absolutely okay… and I’d be eager to hear your perspective on this method.
Special thanks to
Gratitude goes out to the reviewers whose input significantly improved this article! This wouldn’t have been possible without them.

•