Skip to content
By Yash KapureFrontend10 min readMay 29, 2026

Monorepo Architecture for Frontend Teams

A monorepo is not a folder with all your code in it. It is a set of conventions about boundaries. Done right, teams move faster. Done wrong, every build is a CI nightmare.

Article focus

3x faster CI

With proper caching and boundaries

Key takeaways

  • Package boundaries are the architecture of a monorepo. Each package owns its types, tests, and dependencies.
  • Build caching (Turborepo, Nx) is not optional. Without it, monorepo CI times grow linearly with the codebase.
  • Shared configs (TypeScript, ESLint, Prettier) should be a package, not a root file. Version them explicitly.

When does a monorepo make sense?

When you have multiple packages that share types, configs, or tooling - a frontend app, a backend, a shared types library, a design system.

Monorepos are trendy, but they are not free. They require discipline around package boundaries, build caching, and dependency management. If you have a single app with no shared packages, a monorepo adds complexity without benefit.

The inflection point is when you have two or more packages that share code. A Next.js frontend and an Express backend that share TypeScript types (see API Contracts). A design system package and the apps that consume it. A set of utility packages used across multiple projects.

At that point, a monorepo with clear boundaries and efficient caching is significantly faster than publishing and versioning separate packages for every shared piece of code. It is one of the five pillars in the Frontend Architecture framework.

Package Dependency Graph Explorer

Click each package to see what it depends on. Root-level packages have no internal deps.

Shared Types

packages/shared

No internal dependencies - root-level package.

Click each package to see its internal dependencies. Leaves with no deps are root-level.

Package boundaries - the real architecture

Each package is a self-contained unit with its own tsconfig, its own dependencies, and an explicit public API through its index file.

The most common monorepo mistake is treating packages like folders. A package has a boundary. Its internal modules are private. Its exports (what goes through the index.ts barrel) are its public API. Everything else can be refactored without affecting consumers.

I follow three rules for package structure:

typescript

// packages/shared/src/index.ts - explicit public API
export { UserSchema } from './schemas/user';
export { formatCurrency } from './utils/format-currency';
export type { User, Product } from './types';

// Everything else is internal
// ./schemas/product.ts - not exported, can be refactored freely
// ./utils/format-date.ts - not exported, not public API
  • Each package has its own tsconfig.json with strict mode enabled. No global TypeScript config.
  • Each package declares its own dependencies explicitly. No hoisting surprises.
  • Each package exports a minimal surface area through its index file. If it is not exported there, it is private.

Package Boundary Visualizer

Toggle files between public (exported) and private. See how consumers are affected by what you expose.

packages/shared/src/

index.ts ← barrel (public API)

Click any file to toggle its export status

Consumer Impact

apps/web
import { ... } from '@company/shared/schemas/user'
import { ... } from '@company/shared/utils/format-currency'
import { ... } from '@company/shared/types/index'
import { ... } from '@company/shared/schemas/product'
import { ... } from '@company/shared/utils/format-date'
apps/api
import { ... } from '@company/shared/schemas/user'
import { ... } from '@company/shared/utils/format-currency'
import { ... } from '@company/shared/types/index'
import { ... } from '@company/shared/schemas/product'
import { ... } from '@company/shared/utils/format-date'

Private imports break consumers. The barrel protects internals.

Shared TypeScript configs as a package

Put your shared tsconfig, ESLint config, and Prettier config in a package. Other packages extend or depend on it explicitly.

Root-level config files work for small monorepos but break down as the team grows. A root tsconfig.json means every package uses the same compiler options. But what if the backend package needs a different module resolution? What if the design-system package uses JSX while the utility package does not?

The solution is a @company/configs package that exposes base configurations. Each package extends the base and overrides what it needs.

json

// packages/configs/tsconfig/base.json
{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "skipLibCheck": true
  }
}

// apps/web/tsconfig.json
{
  "extends": "@company/configs/tsconfig/base.json",
  "compilerOptions": {
    "jsx": "preserve",
    "lib": ["dom", "dom.iterable"]
  }
}

// packages/utils/tsconfig.json
{
  "extends": "@company/configs/tsconfig/base.json",
  "compilerOptions": {
    "lib": ["ES2022"],
    "declaration": true
  }
}

Config Inheritance Explorer

Click each config file to see how per-package overrides work. Inherited vs overridden vs new properties are color-coded.

packages/configs/tsconfig/base.json
{
strict:true
target:"ES2022"
module:"ESNext"
moduleResolution:"bundler"
skipLibCheck:true
}
override new inherited

Build caching is not optional

Without caching, CI time grows with every package you add. Turborepo or Nx caches outputs by content hash and skips unchanged packages.

The naive monorepo CI runs all the tests for all the packages on every commit. If you have 10 packages, CI takes 10 times as long as a single-package repo. This does not scale.

Turborepo (turbo) and Nx solve this by tracking the dependency graph and caching outputs. If a package has not changed, it reuses the cached output. If only one package changed, only that package and its dependents need to run.

With caching enabled, a CI run where only the web app changed takes seconds - lint and typecheck the web package, build it from cache if unchanged, and deploy. The other 9 packages are cache hits.

json

// turbo.json - minimal caching configuration
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": []
    },
    "lint": {
      "outputs": []
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "outputs": []
    }
  }
}

Build Caching Timeline

Select which package changed and toggle caching on/off to see the impact on build time.

Changed:
Cache:
Configs
Shared Types
Design System
Express API
Next.js App
16srebuild
Without caching: 60s
With caching: 16s
3.8x
faster CI

Dependency management - the silent killer

Use the package manager's workspace feature. Pin versions. Run deduplication regularly. Do not let hoisting surprises leak.

Dependency management in monorepos is harder than in single-package repos because of hoisting. If two packages depend on different versions of the same library, the package manager hoists the shared version to the root node_modules. This can cause hard-to-debug runtime errors where the wrong version is resolved.

Practical rules:

  • Use pnpm or Yarn Berry with strict dependency resolution. Avoid npm - its hoisting algorithm is too permissive.
  • Pin dependency versions across packages. Do not let one package use React 18 and another use React 19.
  • Run deduplication as a CI step. If two versions of the same package exist in the lockfile, the CI should fail.
  • Use the "packageManager" field in the root package.json to enforce the package manager version across the team.

Hoisting Problem Simulator

Toggle between npm and pnpm to see how different package managers resolve conflicting dependency versions.

Package manager:

Web App

package.json
"react": "18.3.1"
Correctly resolved react@18.3.1

Admin Panel

package.json
"react": "19.0.0"
Correctly resolved react@19.0.0

node_modules structure

root/
node_modules/
react@19.0.0
packages/
web/
node_modules/react@18.3.1 ← correct version
admin/
node_modules/react@19.0.0 ← correct version
Strict resolution. Each package gets its declared version. No surprises.

CI strategy for monorepos

Run tasks only for changed packages and their dependents. Use Turborepo's --filter or Nx's affected commands.

The CI pipeline is where monorepos either shine or die. A well-configured CI runs the minimal set of tasks for each commit.

The key insight: do not hardcode which packages to test. Let the build system figure it out from the dependency graph. When package A changes, turbo runs A's tests, then checks if anything depends on A and runs those tests too.

yaml

# .github/workflows/ci.yml
name: CI
on: [pull_request]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v2
      - uses: actions/setup-node@v4

      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo typecheck lint test build
        # turbo runs only affected packages + dependents

CI Task Pipeline Builder

Select a scenario to see which CI tasks run and how long the pipeline takes. Parallel execution is visualized.

Only the web app changed. 6 tasks run in parallel where possible.

Total pipeline time: 26s(parallel stages overlap)
affected-only

Need help implementing this?

I build these systems for a living - let's work on yours.

Frontend Architecture & System Design

Structure so teams can ship. Clear boundaries, state strategy, and contracts. New features land cleanly; refactors stay low-risk.

TypeScriptComponent designState managementAPI contracts+4
Discuss this service

Frontend Development

Production UIs in React, Next.js, or Vue- third-party and payment integrations included. Built for real traffic and maintained in production.

ReactNext.jsTypeScriptTailwind CSSshadcn/ui+5
Discuss this service

Full Stack Development

End-to-end apps: MERN, React+Node, or MEVN. Auth, role-based access, and real-time where it matters. Designed to scale without rewrites.

MERNMEVNNode.jsExpressReact+5
Discuss this service
$ git checkout --your-opinion

Your turn

  • >Did this help you ship something?
  • >Which part clicked the most for you?
  • >Applying this at work? Share your experience.

Discussion

Leave a comment

Thoughts, questions, corrections - all welcome.

Sign in with GitHub to comment - free & takes 10 seconds

Recommended blogs

Continue reading

View all blogs
Laptop on a clean desk in a modern room
Frontend
9 min readMarch 5, 2026

Shipping React UI Fast Without Making a Mess

The way I structure React and Next.js UI so the team ships fast because the system is obvious, not because we skipped every guardrail.

Photo by Zak Chapman on Pexels

Read article
Illustration for JavaScript closures and lexical scope
Frontend
14 min readApril 24, 2026

JavaScript Closures Explained: Why Your Functions Remember Everything

Learn JavaScript closures with interactive demos. Covers lexical scope, the var vs let loop bug, stale React hooks, memory leak patterns, and closure interview questions.

Reference photo by Asad Photo on Pexels

Read article

Subscribe for new posts, or read how referrals and sponsored placements are handled on this site.