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.
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).
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:
// 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
// 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:
// 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.
Internal package versioning — what’s workspace:*?
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.
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/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.
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.
// 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: 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.
Related
Further reading
- Turborepo Handbook: https://turbo.build/repo/docs/handbook
- pnpm Workspaces: https://pnpm.io/workspaces
- Nx — Monorepo Structure: https://nx.dev/concepts/decisions/why-monorepos
- “Bulletproof React” monorepo example: https://github.com/alan2207/bulletproof-react
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 withworkspace:*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 --filterornx 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.