Mounting
The committed symlinks under src/app/, src/pages/ and public/ are the
source of truth. What Next reads is derived from them, and how depends on which
router and which command.
The two routers behave differently
next build follows a symlinked directory, so it reads src/app/ as committed
and needs nothing generated. next dev does not — it returns 404 for every
route underneath a symlinked directory. It does follow a symlinked file, so
development gets a tree of real directories with each route file symlinked
individually.
The Pages Router cannot be symlinked at all. Next resolves a route to its
real path, and a Pages Router route reached through a symlink loses track of its
pages/ root. The build then fails looking for /_document while rendering the
404, which is a confusing symptom of a resolution problem. So pages/ is always
materialised as real files.
Strategies
| Strategy | Produces | Used by |
| --- | --- | --- |
| source | nothing; Next reads src/app/ | next build |
| mirror | app/: real directories, one symlink per file | next dev |
| hardlink / copy | app/: duplicated files | filesystems without usable symlinks |
auto, the default, picks source for builds and mirror for next dev.
mirror is preferred over hardlink because a symlink resolves by path rather
than by inode. An editor that saves by writing a new file and renaming it over
the old one breaks a hardlink, leaving the mounted copy serving stale content; a
symlink is unaffected. A watcher re-derives the tree when files are added or
removed, which is all it has to catch.
public/ needs none of this. Static assets are served straight through the
committed symlink, in development and production alike.
Two consequences worth knowing
Next refuses to run when app and pages live in different folders. An app
with a Pages Router therefore forces the App Router tree to be derived as well,
so source quietly becomes mirror.
And because Next prefers app/ over src/app/ when both exist, a build clears
any app/ left behind by a dev session rather than let it shadow the committed
tree.
Self-reference
Turbopack resolves a mounted file from its real location, so the monolith's
node_modules is not on its lookup path. An app that imports its own modules by
package name — which it should, see Portability — needs
to be able to find itself. withMonolith writes
<app>/node_modules/<package name> pointing at the app, leaving any real
install alone. Pass { selfReference: false } to opt out.
When a module cannot be cloned
A module does not have to come from a submodule. Give an app both a root and a
package, and the checkout is used when its routes are there with the package
as the fallback:
withMonolith(config, {
id: { root: '../id', package: '@fairgarden/id' },
})
An empty directory is what a build host leaves behind when it cannot read a submodule's remote, and that is when the package takes over. A private package installs with a token, which is a credential a build host can be given, whereas a private submodule needs an SSH key it cannot have.
Those files are copied, not linked. Turbopack will not process a route whose
real path is inside node_modules — the build fails with an internal error
about writing an app endpoint, which says nothing about the cause. So a
package-sourced app is materialised outside node_modules automatically, while
path-sourced apps are still symlinked. A committed link that points into
node_modules is skipped for the same reason.
Because such an app has nothing in the committed tree, having one means the derived tree is always built, the same way a Pages Router forces it.