Monorepo Project Tour: How the Workspace Fits Together
A walkthrough of a monorepo workspace structure — apps, packages, workspace protocol, and the build ordering problem you will hit next.
The shift from runtime module federation to build-time composition changes the developer experience immediately: instead of running two dev servers and coordinating between ports, you run one. One pnpm dev, one browser tab at localhost:5173. The architectural complexity moved from runtime coordination to build-time structure.
Here is how that structure is organized.
The Folder Layout
enterprise-ui/
apps/
dashboard/ ← the shell app, references all packages
legacy/ ← for migration exercises later
packages/
analytics/ ← bar charts, metrics views
users/ ← user list, sidebar
ui/ ← shared component library
shared/ ← utilities, types, constants
pnpm-workspace.yaml
package.json ← workspace rootThe naming of apps and packages is convention, not requirement. These could be named anything — the workspace config is what matters. What the folders represent semantically: apps contains deployable applications, packages contains shared code that apps depend on.
Each entry inside packages has its own package.json, its own build script, its own name scoped under a shared namespace (@pulse/ui, @pulse/shared, etc.). From the outside, each looks exactly like an npm package. Internally, it's just a directory.
The Workspace Protocol
The pnpm workspace config (pnpm-workspace.yaml) tells pnpm where to look for local packages:
packages:
- 'apps/*'
- 'packages/*'Then in apps/dashboard/package.json:
{
"dependencies": {
"react": "^18.3.0",
"@pulse/ui": "workspace:*",
"@pulse/shared": "workspace:*",
"@pulse/analytics": "workspace:*"
}
}workspace:* means: don't go to the npm registry for this. Find it in the workspace and link it directly. Running pnpm install resolves all workspace:* references locally — no publishing, no private registry, no token rotation.
This is the same mechanism you'd use if these packages were published to npm. The consuming code doesn't know the difference. You get the same import experience with a fraction of the overhead.
The TypeScript Shortcut Vite Provides
Here's a convenience that's easy to overlook: Vite transpiles TypeScript inline as part of bundling. This means for a simple setup, there's no separate compilation step. You write TypeScript in packages/ui, import it in apps/dashboard, and Vite handles the type stripping when it builds.
This is why the initial setup feels deceptively simple. No build step for each package. No watching for type changes. Just write code and run pnpm dev.
The simplicity breaks down at scale, and it's worth understanding when:
- If a package needs to be compiled first — say,
.sveltefiles that need to become.jsbefore TypeScript can consume them — Vite can't handle that chain automatically. - If packages depend on other packages — and those need to build in a specific order — you need something that understands the dependency graph.
- If you want to run only the tests that are relevant — running 1000 Playwright tests every time someone changes a CSS class is not sustainable.
The workspace and pnpm-workspace.yaml solve the resolution problem (where to find packages). They don't solve the ordering problem (what to build first) or the scoping problem (what to test on a given change).
What's Missing
If I make a CSS change in packages/users and run pnpm -r build (recursive build across all packages), it rebuilds everything. Every package, every test, every time. The full cost.
This is the gap that Turborepo fills. The workspace gives you the structure. The build tool gives you the intelligence about what actually needs to run.
That's the next section.
Keep reading
Enjoyed this? Get more like it.
Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.