Modules
A distribution is apps and the packages they share.
Both are submodules; what differs is whether they have routes.
Apps and packages
add-module reads the checkout to tell them apart.
| has routes | placed at | mounted | |
|---|---|---|---|
| app | yes | apps/<name> | at /<name> |
| package | no | packages/<name> | not mounted |
Added packages/design from https://github.com/acme/design.git
Shipping @acme/design@1.4.0, as the checkout declares it
Linked through the workspace in apps/monolith/package.json
It has no routes, so it is a package the apps depend on rather than one to mount.
A package is depended on, not served. Mounting one would give Next a route tree with no routes in it.
How they resolve
pnpm links every submodule into the workspace, so an app importing
@acme/design gets the checkout sitting next to it. That is what makes the
submodule commit the only pin: there is no published copy to disagree with.
An app that depends on a package should declare it as a peerDependency, so the
workspace supplies it and the app carries no version of its own. Keep a
devDependency at the same range, so the module still builds cloned on its own.
The range it declares is for publishing, and it is not what picks the copy used
here. Linking by range breaks as soon as a submodule is bumped past it — pnpm
resolves the sibling from the registry instead, and the app builds against the
last published version with a newer checkout sitting next to it. The
distribution settles it with overrides, which say
workspace:* for every module it ships.
Two workspaces, one module
A module is checked out in two situations, and each has its own workspace root.
Inside a distribution, the distribution's pnpm-workspace.yaml is the one in
effect. Its globs reach into the submodules and pick up anything nested in
them:
packages:
- "apps/*"
- "packages/*"
# a module's docs site is its own workspace member, so its Next and MDX
# dependencies stay out of the module it documents
- "apps/*/docs"
- "packages/*/docs"
Cloned on its own, none of that exists. So a module with anything nested in it
carries the same arrangement in a pnpm-workspace.yaml of its own:
packages:
# The workspace root — this module — is always a member; these are the
# additions.
- docs
linkWorkspacePackages: true
saveWorkspaceProtocol: false
The settings are not decoration. pnpm 10 defaults linkWorkspacePackages to
false, so without it a docs site that depends on the module it documents
resolves that dependency from the registry — quietly building against the last
published version instead of the checkout it is sitting in. Inside a
distribution the root supplies the setting; standing on its own, the module has
to.
saveWorkspaceProtocol: false keeps plain semver ranges in package.json
rather than workspace:*. A workspace: range cannot be installed from a
clone of the module's own repository — which is where its
publish workflow runs — and the range is also what a
distribution reads to resolve versions.
Install from the root
pnpm uses the nearest workspace file to wherever it is run, not the outermost one. Inside a distribution that means:
pnpm install # at the root: one workspace, the overrides apply
cd packages/design && pnpm install # a second workspace, its own lockfile, no overrides
The second is not an error and says nothing — it just builds a different tree.
Install from the distribution root; use --filter to narrow what you are
working on rather than changing directory first.
extract writes the file for you when the directory it is
carving out has packages inside it, with that warning at the top of it. A module
with nothing nested in it needs no workspace file at all.
Build scripts
A module's build belongs in its own package.json, or in a scripts directory
when it is too long for one line. That way it is versioned with the module and
there is one place to look. Scripts that drive CLI tools are easier to write and
read with zx (external site)(github.com/google/zx) than in bash.
Modules that are not JavaScript
A module need not be a JS project. Give it a package.json so the workspace
sees it and its build is described in one place, and do not publish it to npm.
It is still a submodule, and still versioned by its own tags.
Describing the build there is what keeps the development environment uniform: a module in another language is still built by the same command as the rest.
Public and private
A private distribution may ship some public modules and some private ones, and a private module may extend a public one.
Forking a whole project to change one part of it is not necessary. Make your own distribution, reuse the modules you want, and replace the one you do not with your own fork — see Extending.