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 ownpackage.json, scripts, deploy config).packages/= library, consumed by apps and other packages.tooling/ortooling-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 consumption —
packages/uirunspnpm build→dist/index.js. Apps import fromdist/. Required when consuming vianode_modulesor 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: trueif you need it; comment why. - Circular package deps —
@my/utils→@my/types→@my/utils. Refactor; tools won’t fix. peerDependenciesin internal packages — declare React as peer inpackages/uiso consumers (apps) provide it. Prevents double-React instances.exportsfield in internal packages — needed for subpath imports + tree shaking even within the monorepo.tsconfig.jsonreferencesslow on first build — incremental after. Worth it; needscomposite: trueper referenced package.pnpm installin CI fails on lockfile drift —--frozen-lockfilecatches; 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/orpackages/for configs (ESLint, TS). Internal deps viaworkspace:*.” - “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
--filteror Nxaffected, 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
- Monorepo tool selection: ../09_build_tools/06_monorepo_tools.md
- Package managers: ../09_build_tools/07_package_managers.md
- Feature-based architecture (apps): 01_feature_vs_layer.md
Further reading
- Turborepo Handbook: https://turbo.build/repo/docs/handbook
- pnpm Workspaces: https://pnpm.io/workspaces
- Nx — Monorepo Structure: https://nx.dev/concepts/more-concepts/monorepo-structure
- “Bulletproof React” monorepo example: https://github.com/alan2207/bulletproof-react