Skip to main content

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 routesplaced atmounted
appyesapps/<name>at /<name>
packagenopackages/<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.