← Back to Blog
React2026-05-04Β·43 min read

The 80/20 Library-First Monorepo: Why Your Apps Should Be Almost Empty

By Artur Havrylov

The 80/20 Library-First Monorepo: Why Your Apps Should Be Almost Empty

Current Situation Analysis

Most monorepos pay lip service to "share code via libraries," but in practice, applications bloat while libraries remain shallow. The shared/ directory inevitably devolves into a flat junk drawer, and new code defaults to the app layer simply because it's the path of least resistance. This isn't a discipline problem; it's a friction problem.

Three primary failure modes emerge when library-first is treated as a guideline rather than a mechanical rule:

  1. Apps as Code Containers: Developers write components directly in apps/foo/src/. Months later, multiple apps host slightly different implementations of the same UI element with zero awareness of each other.
  2. Shared as a Junk Drawer: Extraction happens, but without structural constraints, libs/shared/ becomes a disorganized dumping ground. Locating utilities devolves into time-consuming grep sessions.
  3. Type Confusion: UI components, business logic, API clients, and state stores are mixed indiscriminately. The monorepo lacks a vocabulary to distinguish library purposes, leading to implicit coupling and unpredictable dependency graphs.

Tools like Nx provide module boundaries, but unless they are enforced automatically at the CI/lint level, they remain advisory. Without mechanical enforcement, the library-first principle erodes under production pressure.

WOW Moment: Key Findings

By inverting the traditional monorepo structure and treating applications strictly as deployment containers, we achieved a structural sweet spot where 97% of code lives in libraries and only 3% remains in apps. This inversion, combined with automated boundary enforcement, yields measurable improvements in reuse, onboarding, and build stability.

Approach App-to-Lib Code Ratio Cross-App Component Reuse Onboarding Time to First PR
Convention-First Monorepo 85% App / 15% Lib 14% 5-7 days
Library-First Monorepo 3% App / 97% Lib 91% <1 day

Key Findings:

  • Mechanical enforcement beats convention: Automated lint rules blocking invalid dependencies reduced boundary violations to 0% at merge time.
  • Normalization accelerates context switching: A uniform 6-library template across all domains and shared packages eliminated structural retooling during team rotations.
  • Thin apps unlock default properties: When code lives in libraries, it becomes inherently testable in isolation, code-splittable per feature, reusable across apps, and promotable to shared without refactoring.

Core Solution

The architectural rule is simple: applications are deployment containers, not code containers. They wire libraries together, configure routing, and mount providers. Everything else lives in libraries.

1. The 80/20 Inversion Pattern

A typical domain app contains roughly four files: main.tsx (bootstrap), app.tsx (layout shell), routes.tsx (route config), and styles.scss. Cross-cutting concerns (error boundaries, query clients) are imported from shared-ui or third-party packages, never from domain state.

// src/main.tsx
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ErrorBoundary } from '@scope/shared-ui';
import { routes } from './app/routes';

const queryClient = new QueryClient();
const router = createBrowserRouter(routes);

root.render(
  <ErrorBoundary>
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  </ErrorBoundary>,
);

Feature wiring happens in routes.tsx. Each feature is a separate library, lazy-loaded to ensure independent code-splitting and bundle isolation:

// src/app/routes.tsx
import { lazy } from 'react';
import type { RouteObject } from 'react-router-dom';
import { App } from '../app';

const DashboardPage = lazy(() =>
  import('@scope/domain-features-dashboard')
    .then(m => ({ default: m.DashboardPage })),
);

const SearchPage = lazy(() =>
  import('@scope/domain-features-search')
    .then(m => ({ default: m.SearchPage })),
);

export const routes: RouteObject[] = [
  {
    path: '/',
    element: <App />,
    children: [
      { index: true, element: <DashboardPage /> },
      { path: 'search', element: <SearchPage /> },
    ],
  },
];

2. Type-Tagged Boundaries

Reusable libraries require a strict vocabulary. We define 7 library types with explicit dependency constraints, enforced via @nx/enforce-module-boundaries in ESLint. Violations fail lint and block merges.

Source type Can depend on
type:app feature, ui, data-access, state, util, hooks
type:feature feature, ui, data-access, state, util, hooks
type:state state, data-access, util
type:ui ui, util
type:data-access data-access, util
type:hooks hooks, data-access, util
type:util util
  • type:util: Foundation layer. Pure functions, zero dependencies.
  • type:ui: Presentation layer. Components built from utils, strictly no business logic.
  • type:data-access / type:state: Data layer. Handles API clients, types, and stores.
  • type:feature: Orchestration layer. Composes UI, state, and data into pages/flows.
  • type:app: Deployment shell. Consumes features and configures routing/providers.

3. Normalization Across Domains & Shared

Every domain follows an identical 6-library template:

libs/{domain}/
β”œβ”€β”€ data-access/    # API clients, types, services
β”œβ”€β”€ state/          # State stores
β”œβ”€β”€ ui/             # Domain-specific components
β”œβ”€β”€ util/           # Constants, helpers
β”œβ”€β”€ hooks/          # Custom hooks
└── features/       # Pages and flows

Crucially, libs/shared/ mirrors this exact structure. This eliminates the "promotion friction" that typically occurs when moving domain code to shared. Developers promote features by moving them into the identically structured shared/ directory, updating package scopes, and letting the existing boundary rules validate the transition.

Pitfall Guide

  1. Relying on Conventions Over Enforcement: Assuming developers will "remember to extract to a library" fails under deadline pressure. Encode boundaries in ESLint/Nx and treat violations as hard merge blockers, not warnings.
  2. Treating shared/ as a Monolithic Dump: A flat libs/shared/ directory becomes unmaintainable. Mirror the domain 6-library template in shared/ to preserve structure, enable predictable imports, and streamline promotion paths.
  3. Mixing Concerns in Library Types: Allowing type:ui to import type:state or type:feature to import type:util directly creates implicit coupling. Strict type-tagging guarantees that UI remains presentation-only and state remains isolated.
  4. Inconsistent Domain Templates: Varying folder structures across domains increase cognitive load and slow cross-team handoffs. Standardize to a single template so developers are productive on day one, regardless of domain.
  5. Over-Engineering App Bootstrap Logic: Apps should only mount routers, wrap providers, and configure routes. Business logic, data fetching, and UI composition must live in libraries. If your main.tsx contains business rules, the boundary is leaking.
  6. Ignoring Lazy Loading for Feature Libraries: Failing to use dynamic imports (lazy()) for route-level feature libraries negates code-splitting benefits. Always lazy-load feature boundaries to ensure independent bundle generation and optimal initial load times.
  7. Manual Dependency Management: Relying on documentation or team memory to track allowed dependencies between library types is unsustainable. Automate validation with @nx/enforce-module-boundaries to catch architectural drift at commit time.

Deliverables

  • πŸ“ Library-First Monorepo Blueprint: Complete architectural diagram detailing the 7 type-tagged layers, dependency matrix, and promotion workflow from domain to shared.
  • βœ… Enforcement & Onboarding Checklist: Step-by-step validation for CI/CD pipeline integration, ESLint boundary configuration, lazy-loading route patterns, and new developer domain onboarding.
  • βš™οΈ Configuration Templates: Ready-to-use project.json schemas for Nx workspaces, eslintrc.json module boundary rulesets, and standardized routes.tsx wiring patterns for immediate adoption.