Frontend / Project structure / 02_monorepo_layout.md

Monorepo Layout — Apps, Packages, Tooling

Updated 5 interview angles 6 min read source
On this page13
  1. Canonical monorepo layout.
  2. What goes in apps/ vs packages/?
  3. Shared TS config — how to wire?
  4. Shared ESLint config — same pattern?
  5. Internal package versioning — what’s workspace:*?
  6. How do internal packages get built and consumed?
  7. How do tests resolve internal packages?
  8. CI strategy for monorepos.
  9. When does a single repo not want to be a monorepo?
  10. Gotchas
  11. Related
  12. Further reading
  13. Interview angle

Monorepo Layout — Apps, Packages, Tooling

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 Monorepo Tools — Turborepo, Nx, pnpm Workspaces for tool comparison; this file is layout + conventions.

Canonical monorepo layout.

text
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).

What goes in apps/ vs packages/?

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.

Shared TS config — how to wire?

A package that exports tsconfig.json files:

json
// 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 extend it

json
// 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 TypeScript — Senior Interview Prep).

Shared ESLint config — same pattern?

Yes, with ESLint flat config:

js
// 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:

js
// 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.

Internal package versioning — what’s workspace:*?

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

json
// 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.

How do internal packages get built and consumed?

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 consumption — packages/ui runs pnpm build → dist/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.

How do tests resolve internal packages?

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

ts
// 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.

CI strategy for monorepos.

  • 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.

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

  • 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

  • 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.

Further reading

Interview angle 5

  • “What is the standard monorepo layout?” - apps/ for deployable things, packages/ for shared libraries, and tooling configs as packages too. Internal dependencies are declared with workspace:* so the package manager links them rather than fetching from a registry.
  • “Why make ESLint and TypeScript configs into packages?” - so one edit to the base config propagates to every app and library that extends it. Copied config files drift within weeks, and then “why does this rule only fire in that app” becomes a recurring question.
  • “Do internal packages get built, or imported from source?” - source for an internal-only monorepo, since it removes a build step and keeps go-to-definition working. Pre-built dist/ when the packages are also published to npm, because then consumers outside the repo need the artifact.
  • “How do you keep CI from growing linearly with the repo?” - build only what changed, using the dependency graph - turbo --filter or nx affected - plus a remote cache so unchanged packages are downloaded rather than rebuilt, and parallelism across packages.
  • “When is a monorepo the wrong answer?” - when teams have genuinely different release cadences, or when compliance boundaries mean some code should not be readable by everyone. Polyrepo has real costs, and so does forcing unrelated teams into one release process.