DEV Community

Cover image for How a Monorepo (Nx) Keeps Angular, React, and NestJS in Sync - From Shared Code to Atomic Deployments
nsimonoski
nsimonoski

Posted on

How a Monorepo (Nx) Keeps Angular, React, and NestJS in Sync - From Shared Code to Atomic Deployments

Copy-Paste Development

Every company that runs more than one project eventually faces the same problem.

It starts small. Project B needs the same user authentication logic as Project A, so someone copies the auth module over. A few months later, Project C launches and borrows the same code - plus some API utilities from Project B. By the time Project D kicks off, there are four slightly different implementations of the same auth flow, three versions of the same API types, and no one remembers which project has the "correct" version.

Each project drifts. Different ESLint configs. Different TypeScript strictness levels. Different folder structures. A junior developer joins Project C and learns patterns that don't exist in Project A. A bug gets fixed in one project's copy of a utility but never propagated to the others.

The usual answer to this is internal npm packages. Extract the shared code, publish it to a private registry, and have each project depend on it. This helps, but it introduces its own problems: now every change to shared code requires a version bump, a publish step, and a dependency update in every consuming project. Hotfixes become multi-step release processes. And if the shared package has its own dependencies, you're now managing a dependency tree across multiple registries.

The shared code problem doesn't go away - it just moves from copy-pasted files to versioned packages that still need coordination.

A monorepo takes a different approach. Instead of distributing shared code through a registry, it keeps everything in one repository with one source of truth for shared logic, one set of standards, and a build system that understands how projects relate to each other. Whether the projects use the same framework or different ones, the benefits are identical.


What a Multi-Project Monorepo Looks Like

The structure separates shared code from project-specific code:

apps/
  customer-portal/     → Project A
  admin-dashboard/     → Project B
  api/                 → Backend API

libs/
  shared/              → Used by everything
    contracts/         → TypeScript interfaces, DTOs, enums
    utils/             → Pure utility functions
    styles/            → SCSS variables, mixins, themes

  customer-portal/     → Project A specific libraries
    features/          → Feature modules
    shared/            → UI components, stores

  admin-dashboard/     → Project B specific libraries
    features/          → Feature modules
    shared/            → UI components, stores
Enter fullscreen mode Exit fullscreen mode

Every project has its own space. Shared code lives in one place. The boundary between "mine" and "ours" is explicit in the folder structure.

In a real production codebase - a browser-based IDE (kod3.dev, Github Source Code) with multiple frontends and a NestJS backend - this breaks down to:

Layer Lines of Code % of Total
Shared (contracts, utils, terminal, styles) ~4,850 20%
Frontend A (Angular) ~8,030 33%
Frontend B (React) ~3,800 16%
Backend API (NestJS) ~3,200 13%
Application shells ~1,470 6%

That 20% of shared code is consumed by every project in the repo. Without the monorepo, those ~4,850 lines would need to exist independently in each project - copied, maintained separately, and slowly diverging. That's roughly 10,000 lines of duplication avoided.


Benefit 1: Shared Contracts - Define Once, Use Everywhere

The highest-leverage shared code is contracts: the TypeScript interfaces that define how data flows between services and projects.

Consider a git status interface used across the entire stack:

// libs/shared/contracts/src/lib/git.contract.ts

export interface GitStatusDto {
  branch: string;
  tracking: boolean;
  ahead: number;
  behind: number;
  staged: GitFileChange[];
  unstaged: GitFileChange[];
  untracked: string[];
}

export interface GitCommitRequestDto {
  message: string;
}

export const GIT_CHANGE_EVENT = 'git:change';
Enter fullscreen mode Exit fullscreen mode

This single file is the source of truth for what git status data looks like. The backend returns it:

// Backend API

import type { GitStatusDto } from '@org/shared/contracts';

@Controller('git')
export class GitController {
  @Get('status')
  async status(@Query('path') path: string): Promise<GitStatusDto> {
    return this.service.status(path);
  }
}
Enter fullscreen mode Exit fullscreen mode

Project A consumes it:

// Project A (Angular)

import { GitStatusDto } from '@org/shared/contracts';

@Injectable({ providedIn: 'root' })
export class GitService {
  getStatus(path: string): Observable<ApiResult<GitStatusDto>> {
    return this.http
      .get<GitStatusDto>(`${this.API_BASE}/status`, { params: { path } })
      .pipe(apiResult());
  }
}
Enter fullscreen mode Exit fullscreen mode

Project B consumes the exact same interface:

// Project B (React)

import type { GitStatusDto } from '@org/shared/contracts';

export const gitService = {
  getStatus: (path: string) =>
    apiResult(axios.get<GitStatusDto>(`${API}/status`, { params: { path } }).then((r) => r.data)),
};
Enter fullscreen mode Exit fullscreen mode

Now add a new field to GitStatusDto. The TypeScript compiler immediately surfaces every file - across every project - that needs updating. All in the same build, from the same commit, before anything reaches production.

Compare this to the multi-repo alternative: update the types package, publish a new version, bump the dependency in Project A, open a PR, wait for review, merge, then repeat for Project B. If someone forgets to bump, the frontend silently ignores the new fields until a user reports something broken.

Shared Utilities: Same Logic, Tested Once

Beyond contracts, pure utility functions represent the next layer of high-value sharing:

// libs/shared/utils/src/index.ts

export * as FileUtils from './lib/file-utils/index';
export * as GitTreeUtils from './lib/git-tree-utils/git-tree.utils';
export * as MonacoUtils from './lib/monaco-utils/index';
export * as TabManager from './lib/tab-manager/tab-manager';
export * as ResizeUtils from './lib/resize-utils/resize-utils';
export { browserStorage } from './lib/browser-storage/browser-storage';
export { apiResult } from './lib/api-result/api-result';
Enter fullscreen mode Exit fullscreen mode

GitTreeUtils.buildGitChangesTree() transforms flat git status data into a tree structure. It's used by the backend building the API response AND by frontend stores processing that response. Same function, same logic, tested once, maintained in one place.

MonacoUtils configures the Monaco code editor. TabManager handles open/close/reorder logic. browserStorage wraps localStorage. None of these depend on any framework - they're pure TypeScript that works everywhere.

In a multi-repo world, this kind of code either gets copy-pasted between projects (and slowly diverges) or gets published as an internal npm package (and becomes another thing to version, release, and keep in sync). In a monorepo, it's just an import.

What About Validation?

There's a tension worth addressing. Shared contracts are TypeScript interfaces - they exist only at compile time. But NestJS needs classes with runtime validation decorators to reject malformed requests. Frontends only need the type information. Where does validation logic live?

There are two solid approaches.

Approach A: Interfaces + Separate Validation Classes

The shared contract stays a pure interface. The NestJS backend creates a class that implements it and adds validation decorators:

// libs/shared/contracts (shared, pure interface)

export interface GitCommitRequestDto {
  message: string;
}
Enter fullscreen mode Exit fullscreen mode
// apps/nestjs-api (backend only)

import { IsString, IsNotEmpty } from 'class-validator';
import type { GitCommitRequestDto } from '@org/shared/contracts';

export class GitCommitValidation implements GitCommitRequestDto {
  @IsString()
  @IsNotEmpty()
  message: string;
}
Enter fullscreen mode Exit fullscreen mode

The implements keyword is key - if the interface changes, TypeScript forces the validation class to update too. The shared contract stays framework-agnostic. Frontends are completely unaffected.

The tradeoff is duplication: the interface says "message is a string" and the class repeats it with @IsString(). Validation rules live only in the backend.

Approach B: Zod Schemas as the Single Source of Truth

Instead of a plain interface, the shared contract exports a Zod schema. The TypeScript type is derived from the schema, so the shape and validation rules are defined once:

// libs/shared/contracts (shared, Zod schema)

import { z } from 'zod';

export const GitCommitRequestSchema = z.object({
  message: z.string().min(1, 'Commit message is required'),
});

export type GitCommitRequestDto = z.infer<typeof GitCommitRequestSchema>;
// → { message: string }
Enter fullscreen mode Exit fullscreen mode

NestJS wraps the schema into a DTO using nestjs-zod:

// apps/nestjs-api (backend only)

import { createZodDto } from 'nestjs-zod';
import { GitCommitRequestSchema } from '@org/shared/contracts';

export class GitCommitDto extends createZodDto(GitCommitRequestSchema) {}
Enter fullscreen mode Exit fullscreen mode

Frontends import the type exactly as before - no changes needed:

// Any frontend
import type { GitCommitRequestDto } from '@org/shared/contracts';
Enter fullscreen mode Exit fullscreen mode

But they can also reuse the validation schema for client-side form validation:

// React form validation
import { GitCommitRequestSchema } from '@org/shared/contracts';

const result = GitCommitRequestSchema.safeParse(formData);
if (!result.success) {
  setErrors(result.error.format());
}
Enter fullscreen mode Exit fullscreen mode

Both approaches work well in a monorepo. Approach A is simpler to adopt incrementally. Approach B pays off when validation consistency across the full stack matters.


Benefit 2: Enforced Standards - One Set of Rules for Everyone

With a multi-repo setup, each project develops its own conventions. Project A uses a feature-based folder structure, Project B organizes by file type. Project A handles HTTP errors through interceptors, Project C does it inline in every service call. State management looks completely different across projects - one uses signals, another still relies on BehaviorSubjects. Moving between projects becomes unnecessarily hard.

A monorepo eliminates this by sharing configuration files at the root level: one tsconfig.base.json, one ESLint config, one Prettier config. Every project inherits the same rules.

But shared config alone isn't enough. The real power comes from module boundary enforcement. Nx allows tagging every project by scope and type, then defining rules for what can depend on what:

// eslint.config.mjs

'@nx/enforce-module-boundaries': ['error', {
  depConstraints: [
    {
      sourceTag: 'type:app',
      onlyDependOnLibsWithTags: [
        'type:feature', 'type:ui', 'type:util',
        'type:data-access', 'type:contracts',
      ],
    },
    {
      sourceTag: 'type:ui',
      onlyDependOnLibsWithTags: ['type:ui', 'type:contracts', 'type:util'],
    },
    {
      sourceTag: 'type:util',
      onlyDependOnLibsWithTags: ['type:util', 'type:contracts'],
    },
    {
      sourceTag: 'type:data-access',
      onlyDependOnLibsWithTags: ['type:ui', 'type:util', 'type:contracts'],
    },
  ],
}]
Enter fullscreen mode Exit fullscreen mode

In plain English:

  • A utility can only import other utilities and contracts. It can never reach up to a feature or a store.
  • A UI component can import other UI components, contracts, and utils. It cannot import stores or features. This keeps components pure and reusable.
  • Data-access stores can import utils and contracts. They cannot import features - keeping state management decoupled from view logic.
  • Features are the orchestrators. They wire stores to components and can import from all library types.

If someone writes an invalid import - say a UI component importing a data-access store - the linter catches it:

error  A project tagged with "type:ui" can only depend on libs tagged with
       "type:ui", "type:contracts", "type:util"
       @nx/enforce-module-boundaries
Enter fullscreen mode Exit fullscreen mode

The PR can't merge. The architecture polices itself.

This is something no amount of documentation or code review discipline can achieve in a multi-repo. Rules that exist only in a wiki get ignored. Rules that break the build get followed.


Benefit 3: Build Only What Changed

The most common objection to monorepos is CI speed. "If everything is in one repo, doesn't every PR build everything?"

No. Nx traces the project dependency graph and runs tasks only for projects impacted by the change:

# CI pipeline - every pull request
- run: npx nx affected -t lint build --base=$NX_BASE --head=$NX_HEAD
Enter fullscreen mode Exit fullscreen mode

nx affected compares the PR branch against the base, identifies changed files, walks the dependency graph to find impacted projects, and runs tasks only for those.

What Changed What Gets Built
A feature in Project A Only Project A and its dependencies
A shared contract Every project that imports it
A shared utility Only projects that depend on that utility
A README file Nothing - no project depends on docs

On top of affected commands, Nx caches task results:

// nx.json

"targetDefaults": {
  "@angular/build:application": {
    "cache": true,
    "inputs": ["production", "^production"]
  },
  "@nx/js:swc": {
    "cache": true,
    "inputs": ["production", "^production"]
  }
}
Enter fullscreen mode Exit fullscreen mode

The production named input excludes test files and configs. So editing a test file invalidates the test cache but not the build cache. The build replays from cache instantly.

With Nx Cloud, the cache becomes distributed - a build that ran on one developer's machine is available to the entire team and CI. The first person pays the cost; everyone else gets a cache hit.


Benefit 4: Atomic Deployments

In a multi-repo world, deployments are independent. The API deploys on Tuesday, Project A on Wednesday, Project B on Thursday. For two days, something is out of sync. If the API shipped a breaking change, Project B runs against the wrong contract version until someone notices.

A monorepo deploys everything from the same commit:

# deploy.yml - triggered on push to dev

- name: Lint
  run: npx nx affected -t lint

- name: Build Project A
  run: npx nx build angular-ide --configuration=production

- name: Build Project B
  run: npx nx build react-ide

- name: Build API
  run: npx nx build nestjs-api

- name: Deploy Project A
  run: rsync -avz --delete dist/apps/angular-ide/ deploy@server:/var/www/app/angular/

- name: Deploy Project B
  run: rsync -avz --delete apps/react-ide/dist/ deploy@server:/var/www/app/react/

- name: Deploy API
  run: rsync -avz apps/nestjs-api/dist/ deploy@server:/opt/api/

- name: Restart Services
  run: ssh deploy@server "cd /opt/api && docker compose up -d --build"
Enter fullscreen mode Exit fullscreen mode

One push. One pipeline. Every project deployed from the same source of truth.

There's never a window where Project A expects a field the API hasn't deployed yet. There's no "which API version is this frontend running against?" question. Contracts match because they come from the same commit.


Benefit 5: Single Version Policy

External Dependencies

This might be the most underrated benefit. In a multi-repo world, each project has its own package.json. Over time, they drift. Project A upgrades to Angular 21. Project B is still on Angular 16 because no one had time to migrate. Project C is stuck on Angular 13 because it depends on a library that never updated. A developer moves from Project B to Project A and discovers that half the patterns they learned don't exist in the older version. A security patch lands for Angular 18+ only - and two of three projects can't receive it.

The same happens with every dependency. One project runs RxJS 7, another RxJS 6. One uses TypeScript 5.5 strict mode, another is on 4.9 with half the checks disabled. The node_modules across projects become parallel universes.

A monorepo has one package.json. One version of Angular. One version of RxJS. One version of TypeScript. When a framework upgrade happens, it happens everywhere at once. Every project gets the security patches, the performance improvements, and the new APIs at the same time. No project falls behind because "we'll migrate later" - later never comes.

This also simplifies onboarding. A new developer learns one set of APIs, one set of patterns, one set of tooling. There's no "which version does this project use?" question.

Internal Shared Code

The same principle applies to shared libraries within the repo. In multi-repo setups, shared packages need semantic versioning. Consumers need to bump dependencies. Breaking changes mean major version bumps, migration guides, and inevitably one project staying on the old version for months.

The worst case is the diamond dependency problem: Project A depends on Shared v2, Project B depends on Shared v1, and both talk to the same API. Two versions of the same types in play, and runtime behavior becomes unpredictable.

In a monorepo, this can't happen. There's one version of @org/shared/contracts - the one at HEAD. Every project uses it. A breaking change forces all consumers to update in the same commit. The reviewer sees the full picture in a single pull request.

Git history becomes meaningful across the full stack:

feat(git): add conflict detection

- Add `conflicted` field to GitStatusDto
- Update API to detect merge conflicts
- Add conflict indicators to Project A
- Add conflict indicators to Project B
Enter fullscreen mode Exit fullscreen mode

One commit. Complete traceability. Reviewable and revertable as a unit.


Even Competing Frameworks Can Coexist

If shared contracts, enforced boundaries, and affected builds work across multiple projects that use the same framework - they also work across projects that use different frameworks.

The production codebase these examples come from runs Angular and React side by side. Two frameworks with fundamentally different paradigms. Angular uses dependency injection and observables. React uses hooks and promises. NestJS uses decorators and its own module system.

Yet they all import from @org/shared/contracts. They all share @org/shared/utils. They all get validated by the same ESLint rules and deployed by the same pipeline.

The same GitStatusDto flows from a NestJS controller decorated with @Get('status'), through an Angular service returning an Observable<ApiResult<GitStatusDto>>, and through a React service returning a Promise<ApiResult<GitStatusDto>>. Different HTTP clients, different paradigms, one interface governing the data shape everywhere.

If even competing frameworks can coexist in the same repo without stepping on each other - then two Angular projects sharing an auth module, three React apps sharing a design system, or five microservices sharing API contracts is a trivially simpler version of the same pattern.

The multi-framework case is the stress test. The single-framework, multi-project case is the easy win.


The Tradeoffs

This approach has real costs that should be weighed honestly:

Tooling investment. Nx has a learning curve - project tags, dependency constraints, named inputs all need upfront configuration. But this is a one-time cost that pays off on every PR after.

Shared code changes ripple. When a contract changes, every consuming project rebuilds and retests. But that's the point - you catch breakage in CI instead of in production.

Repository size grows. More code, more history in a single repo. But nx affected and distributed caching mean CI only builds what changed, regardless of repo size.

Team coordination on shared code. Merge conflicts increase when multiple teams touch the same shared libraries. But clear ownership and thin shared layers keep this manageable - and the alternative is silent divergence across repos.

Overhead for a single project. A monorepo with one application feels like unnecessary tooling. But most companies don't stay at one project forever. When the second project launches, the shared contracts, enforced boundaries, and build infrastructure are already in place.


About This Monorepo

The codebase used in this post (kod3.dev, source code) started as a portfolio project to showcase Nx and prove that even competing frameworks can coexist in a single repo. But it keeps growing as new ideas come in - voice control, AI integration, a terminal emulator - and the monorepo structure makes it easy to add features without reorganizing the entire project.

One thing worth noting: the libraries aren't split into dozens of tiny packages. A shared utils lib contains FileUtils, GitTreeUtils, MonacoUtils, TabManager, and more - all in one library. What keeps this manageable is named barrel exports:

// libs/shared/utils/src/index.ts

export * as FileUtils from './lib/file-utils/index';
export * as GitTreeUtils from './lib/git-tree-utils/git-tree.utils';
export * as MonacoUtils from './lib/monaco-utils/index';
export * as TabManager from './lib/tab-manager/tab-manager';
export * as ResizeUtils from './lib/resize-utils/resize-utils';
Enter fullscreen mode Exit fullscreen mode

Consumers import FileUtils.getExtension() or GitTreeUtils.buildGitChangesTree() - the namespace makes it clear where every function comes from, even when multiple utilities live in the same library.

The tradeoff is that changing a single file in shared/utils triggers a rebuild of every project that depends on the library. When that becomes a problem - when one utility changes frequently and causes unnecessary rebuilds - that's the signal to split it into its own library. Named barrel exports make the code readable from the start, and Nx makes the split straightforward when the time comes. You don't need to start with fifty micro-libraries to end up with a clean architecture.


Getting Started

For teams considering this approach:

1. Start with contracts. Extract shared TypeScript interfaces into a dedicated library. This is the highest-leverage move - immediate type safety across every project that consumes the same API.

2. Add module boundaries early. Tagging projects from the start is straightforward. Untangling circular dependencies six months later is not.

3. Keep the shared layer thin. Share what must be shared: contracts, pure utility functions, and cross-cutting concerns. Project-specific logic stays in project-specific directories.

4. Namespace imports clearly. Path aliases like @org/shared/contracts, @org/project-a-data-access, and @org/project-b-ui make it immediately obvious which layer and project an import belongs to.

5. Visualize dependencies regularly. nx graph renders the project dependency graph. When an unexpected arrow appears, fix it while it's still one import to remove - not an architectural refactor.


Conclusion

A monorepo isn't about putting files in one folder. It's about creating a system where shared logic has one source of truth, standards are enforced by tooling instead of documentation, builds are intelligent enough to skip what hasn't changed, and deployments are atomic across every project.

The 20% of shared code in a well-structured monorepo isn't about reducing keystrokes. It's about ensuring that when a DTO changes, every consumer across every project updates together, breaks together, and ships together. The alternative - copy-pasted code, diverging conventions, staggered deployments, and runtime type mismatches - costs more than any monorepo tooling ever will.

If you're running a monorepo, considering one, or have questions about anything covered here - drop a comment below. I'd love to hear how other teams are handling shared code across multiple projects.

Top comments (0)