← All articles

Developer Experience at Scale: Monorepo Tooling, Build Speed, and Shared Configs

A slow CI pipeline and copy-pasted configs across packages are tech lead problems, not developer problems. Nobody else will fix them if you do not. Here is what a well-set-up frontend monorepo looks like in practice.

Developer experience is one of those responsibilities that belongs to no one in particular — and therefore to the tech lead by default. When a new engineer takes four days to set up their local environment, when CI runs take eighteen minutes, when the same ESLint rule is defined differently in six packages: these are symptoms of a DX debt that compounds with every new hire and every new package.

This article covers the tooling layer of a frontend monorepo, the choices that matter, and the ones that do not.

Monorepo vs Polyrepo: The Actual Trade-Off

The decision is not primarily technical. It is organisational. A monorepo makes sense when:

  • Multiple packages share code that changes together
  • You want atomic commits across package boundaries
  • One team owns most of the packages

A polyrepo makes more sense when packages have genuinely independent release cycles and are owned by different teams with different deployment cadences. The mistake is choosing a monorepo for a single shared UI library and then discovering that the tooling overhead exceeds the sharing benefit.

Turborepo vs Nx: The Honest Comparison

Both are build orchestrators — they run tasks (build, test, lint) across packages in the right order, with caching. The differences that actually matter in practice:

Summary Turborepo is lighter to set up and gets out of your way. Nx has more structure, more power for enforcing boundaries, and more configuration overhead. For a team of fewer than 15 engineers, Turborepo is almost always the right default.

The Turborepo task pipeline configuration is intentionally minimal:

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],  // build dependencies first
      "outputs": [".next/**", "dist/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "cache": true
    },
    "lint": {
      "cache": true
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

With this config, turbo run build builds packages in topological order, caches outputs, and skips any package whose inputs have not changed since the last run.

Remote Caching

Local caching helps individual developers. Remote caching means CI benefits from what a developer already built locally — and vice versa. The first developer to build a package after a PR merge warms the cache for everyone else.

# CI configuration — connect to Vercel remote cache
# (Turborepo supports custom cache servers too)
- name: Build with remote cache
  run: npx turbo run build --team=${{ vars.TURBO_TEAM }}
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM:  ${{ vars.TURBO_TEAM }}

In our monorepo, enabling remote caching reduced median CI build time from 14 minutes to 3 minutes for PRs that touched a single package. The cache hit rate on the first week was 71%.

Shared Configs: The Right Way

Copy-pasting ESLint and TypeScript configs across packages creates a maintenance problem: rules drift, packages enforce different standards, and fixing a common issue requires touching every config file. The solution is packages that export configs:

// packages/config-eslint/index.js
/** @type {import("eslint").Linter.Config} */
module.exports = {
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:react-hooks/recommended',
  ],
  rules: {
    'no-console':                  ['warn', { allow: ['warn', 'error'] }],
    '@typescript-eslint/no-explicit-any': 'error',
    'react-hooks/exhaustive-deps': 'error',
  },
};

// packages/config-eslint/react-internal.js — for UI packages
module.exports = {
  ...require('./index.js'),
  rules: {
    ...require('./index.js').rules,
    'jsx-a11y/anchor-is-valid': 'error',
  },
};
// apps/dashboard/.eslintrc.js — consumer
module.exports = {
  root: true,
  extends: ['@acme/eslint-config/react-internal'],
};

The same pattern works for TypeScript:

// packages/config-typescript/base.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "moduleResolution": "bundler",
    "target": "ES2022",
    "lib": ["ES2022"]
  }
}

// apps/dashboard/tsconfig.json
{
  "extends": "@acme/typescript-config/base.json",
  "compilerOptions": {
    "lib": ["ES2022", "DOM"],
    "jsx": "preserve"
  },
  "include": ["src", "next-env.d.ts"]
}

Enforcing Module Boundaries

Without boundaries, every package eventually imports from every other package, dependency graphs become acyclic in name only, and build times explode. Nx has a first-class module boundary system; in Turborepo you can approximate it with an ESLint rule:

// packages/config-eslint/boundaries.js
module.exports = {
  plugins: ['import'],
  rules: {
    // UI packages must not import from apps directly
    'import/no-restricted-paths': ['error', {
      zones: [{
        target: './packages/ui',
        from:   './apps',
        message: 'UI packages must not depend on app-level code.',
      }],
    }],
  },
};

Measuring Developer Experience

DX improvement is hard to justify without data. The metrics we track:

  • Time to first green CI on a new PR — target under 5 minutes
  • Time to first commit for a new engineer — tracked in onboarding retros
  • Cache hit rate in CI — below 60% means your cache keys or outputs are misconfigured
  • Number of flaky tests per week — flakiness is a DX tax that erodes trust in CI
Quick win Add a scripts/doctor.sh (or .ts) that checks Node version, validates .env file against .env.example, and confirms all workspace deps are installed. Run it as the first step of your onboarding doc. It catches 80% of new-hire setup issues before they become a Slack question.

Key Takeaways

  • Choose a monorepo for organisational reasons, not technical ones — the tooling overhead is real
  • Turborepo for most teams under 15 engineers; Nx when you need strict boundary enforcement or plugin ecosystem
  • Remote caching is the single highest-ROI DX improvement available — enable it before anything else
  • Shared config packages eliminate rule drift and reduce the cost of cross-cutting lint/TS changes
  • Measure DX with concrete numbers; "it feels slow" does not get prioritised, "CI takes 14 minutes" does