frontend / project structure / 02_monorepo_layout.md

Monorepo Layout — Apps, Packages, Tooling

5 min read source

Monorepo Layout — Apps, Packages, Tooling

TL;DR

A monorepo holds multiple deployable apps + shared libraries in one repo. The common layout: apps/ for deployables, packages/ for internal libraries, tooling/ for shared dev configs. The senior choices: what becomes a package vs stays in an app, how to share TypeScript configs, how internal package versioning works (workspace:* vs SemVer), and CI strategy for affected-only builds. See ../09_build_tools/06_monorepo_tools.md for tool comparison; this file is layout + conventions.

Interview Q&A

Q: Canonical monorepo layout.

A:

my-monorepo/
├── package.json                  # root: scripts, devDeps, workspaces
├── pnpm-workspace.yaml
├── turbo.json                    # task orchestration
├── tsconfig.base.json            # shared TS config
├── .changeset/                   # release management
├── .github/workflows/            # CI
├── apps/
│   ├── web/                      # Next.js marketing/app
│   ├── admin/                    # Vite admin SPA
│   ├── api/                      # backend (Node/Fastify/Hono)
│   └── docs/                     # documentation site
├── packages/
│   ├── ui/                       # design system
│   ├── api-client/               # generated/wrapped API client
│   ├── utils/                    # shared utilities
│   ├── eslint-config/            # shared ESLint flat config
│   ├── tsconfig/                 # shared TS configs
│   └── types/                    # shared types (DTOs, etc.)
└── tooling/                      # one-off scripts, CI helpers
    └── scripts/

Separation:

  • apps/ = deployable (each has its own package.json, scripts, deploy config).
  • packages/ = library, consumed by apps and other packages.
  • tooling/ or tooling-config/ = configs shared as packages (ESLint, Prettier, TS).

Q: What goes in apps/ vs packages/?

A:

In apps/ In packages/
Deployable units Reusable libraries
next.config.ts, vite.config.ts, Dockerfile Build output (dist/), npm-publishable
Routes, app shell, env-specific config Components, hooks, utilities, schemas
Business orchestration Domain primitives

Rule of thumb: if pnpm dev for it produces something a user visits, it’s an app. If pnpm build produces something another package/app imports, it’s a package.

Q: Shared TS config — how to wire?

A: A package that exports tsconfig.json files:

// packages/tsconfig/base.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true
  }
}

// packages/tsconfig/react-library.json
{
  "extends": "./base.json",
  "compilerOptions": {
    "jsx": "react-jsx",
    "lib": ["DOM", "DOM.Iterable", "ES2022"]
  }
}

// packages/tsconfig/package.json
{
  "name": "@my/tsconfig",
  "files": ["base.json", "react-library.json", "nextjs.json", "node.json"]
}

Consumers:

// packages/ui/tsconfig.json
{
  "extends": "@my/tsconfig/react-library.json",
  "include": ["src"],
  "compilerOptions": {
    "outDir": "dist",
    "composite": true
  }
}

One change to the base propagates everywhere. The composite flag enables project references for incremental builds (see ../04_typescript/).

Q: Shared ESLint config — same pattern?

A: Yes, with ESLint flat config:

// packages/eslint-config/index.js
import js from "@eslint/js";
import tsEslint from "typescript-eslint";
import reactPlugin from "eslint-plugin-react";
import boundaries from "eslint-plugin-boundaries";

export default [
  js.configs.recommended,
  ...tsEslint.configs.recommended,
  reactPlugin.configs.flat.recommended,
  {
    rules: {
      "react/react-in-jsx-scope": "off",
      "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
    },
  },
];

// packages/eslint-config/package.json
{
  "name": "@my/eslint-config",
  "main": "./index.js",
  "type": "module"
}

Consumers:

// apps/web/eslint.config.js
import baseConfig from "@my/eslint-config";
export default [...baseConfig];

Override per-app where it makes sense; the base evolves once.

Q: Internal package versioning — what’s workspace:*?

A: When a package depends on another package in the same monorepo:

// apps/web/package.json
{
  "dependencies": {
    "@my/ui": "workspace:*",
    "@my/utils": "workspace:^",
    "react": "^18.2.0"
  }
}
  • workspace:* — always use the workspace version (no SemVer match).
  • workspace:^ — workspace version, replaced at publish time with the actual SemVer.
  • workspace:~ — like ^ but patch only.

pnpm/yarn workspaces resolve workspace:* to a symlink to the local package, so editing @my/ui/src/Button.tsx immediately affects apps/web without rebuild/publish.

At publish time (rare for app-only monorepos, common for library monorepos), tools like Changesets convert workspace:^ to the actual published version (^1.4.0) so external consumers can install.

Q: How do internal packages get built and consumed?

A: Two strategies:

  • Source consumption — apps import directly from packages/ui/src/Button.tsx. Bundler transforms on the fly. Works for ESM + TS-first projects. No build step needed.
  • Pre-built consumptionpackages/ui runs pnpm builddist/index.js. Apps import from dist/. Required when consuming via node_modules or when types are emitted separately.

Modern Vite/Next.js stacks lean toward source consumption (transitive transform handles it). Library monorepos that publish to npm pre-build.

Q: How do tests resolve internal packages?

A: Same workspace links. Vitest/Jest understand workspace symlinks. Type-only imports work via TS path mapping or project references.

// vitest.config.ts in apps/web
export default defineConfig({
  test: {
    alias: {
      "@my/ui": resolve(__dirname, "../../packages/ui/src"),
    },
  },
});

Or trust the workspace symlink — usually works out of the box.

Q: CI strategy for monorepos.

A:

  • Affected-only builds — Turbo (--filter=...[origin/main]) or Nx (affected) walk the package graph from changed files; only rebuild/test affected packages.
  • Shared cache — Turbo Remote Cache or Nx Cloud → CI cache hits across runs and across teammates.
  • Parallel by package — one CI job per package, run in parallel.
  • Independent deploys — each app deploys separately based on what changed. Don’t redeploy the marketing site for a backend change.

A naive monorepo CI that rebuilds everything on every PR is slow and wasteful. Affected-only + remote cache makes a 100-package monorepo feel like a single repo.

Q: When does a single repo not want to be a monorepo?

A:

  • Different teams with different release cadences that don’t share code — they fight for the same CI/repo lock.
  • Different security boundaries (a contractor with access to one app shouldn’t read another).
  • Different compliance requirements (an audited app vs an experimental one).

Sometimes polyrepo (one repo per app) is the right answer. The “should I monorepo?” decision is a team-shape question, not a tech question.

Gotchas / edge cases

  • Hoisted vs nested node_modules (pnpm strict vs shamefully-hoist) — strict catches phantom deps but breaks some legacy packages. shamefully-hoist: true if you need it; comment why.
  • Circular package deps@my/utils@my/types@my/utils. Refactor; tools won’t fix.
  • peerDependencies in internal packages — declare React as peer in packages/ui so consumers (apps) provide it. Prevents double-React instances.
  • exports field in internal packages — needed for subpath imports + tree shaking even within the monorepo.
  • tsconfig.json references slow on first build — incremental after. Worth it; needs composite: true per referenced package.
  • pnpm install in CI fails on lockfile drift--frozen-lockfile catches; commit lockfile updates.
  • Path aliases (@/) vs workspace packages (@my/ui) — pick one mental model per project; mixing is confusing.

What a senior is expected to say

  • “Standard layout: apps/ for deployables, packages/ for shared libraries, tooling/ or packages/ for configs (ESLint, TS). Internal deps via workspace:*.”
  • “Shared TS + ESLint configs are themselves packages. One change to the base propagates.”
  • “Source consumption (import from src/) for in-app monorepos; pre-built (dist/) for npm-publishing library monorepos.”
  • “CI strategy: affected-only via Turbo --filter or Nx affected, remote cache for cross-build hits, parallel per package.”
  • “Don’t monorepo for the sake of it. Teams with different cadences/security/compliance may want polyrepo.”

Cross-references

Further reading