Testing Local Packages with npm link and yalc
You are changing a library and want to try it inside an application before publishing. npm link is the built-in answer, and it works — until the application renders "Invalid hook call", TypeScript finds two copies of the same type, or the bundler refuses to watch files outside the project. Those failures come from how links work, not from your code. This guide explains what npm link actually does, the specific problems it causes, and when to use yalc, file: dependencies or a workspace instead.
Exact symptoms and error messages
The classic npm link failure in a React application:
Warning: Invalid hook call. Hooks can only be called inside of the body of a function component.
This could happen for one of the following reasons:
...
3. You might have more than one copy of React in the same app
TypeScript reports incompatible copies of the same type:
error TS2322: Type 'import("/home/dev/src/my-lib/node_modules/@types/react/index").ReactNode' is not assignable to type 'import("/home/dev/src/app/node_modules/@types/react/index").ReactNode'.
And bundlers sometimes refuse to process or watch the linked folder:
[vite] The request url "/home/dev/src/my-lib/dist/index.js" is outside of Vite serving allow list.
How npm link works
npm link is two steps. Running it inside the library creates a global symlink from npm's global node_modules to your library folder. Running npm link my-lib inside the application replaces app/node_modules/my-lib with a symlink to that global link. The application now imports your working tree directly — including the library's own node_modules directory.
That last step is the root of most problems. Node.js and bundlers resolve imports relative to the real path of the importing file. Your library's files really live in ~/src/my-lib, so when they import 'react', resolution walks up from there and finds ~/src/my-lib/node_modules/react — the copy installed for the library's own development — rather than the application's React. Two React copies, two sets of hooks state, and the invalid hook call warning. The same mechanism produces duplicate TypeScript types and duplicate singletons of any library that keeps module-level state.
Fixing npm link, or replacing it
Option 1: keep npm link, dedupe the peers
If you stay with npm link, force shared peers to resolve from the application. With Vite:
// app/vite.config.ts
export default defineConfig({
resolve: { dedupe: ['react', 'react-dom'] },
server: { fs: { allow: ['..'] } }, // allow serving the linked folder
});
With webpack, alias the peers to the application's copies:
resolve: {
alias: {
react: path.resolve(__dirname, 'node_modules/react'),
'react-dom': path.resolve(__dirname, 'node_modules/react-dom'),
},
},
Alternatively, delete the peer packages from the library's node_modules while linked, so resolution falls through — fragile, because the next install brings them back.
Option 2: yalc — publish locally, install as a copy
yalc simulates publishing: it packs your library using the same file rules as npm publish, stores it in a local store, and copies it into the application's node_modules (not a symlink), where it resolves dependencies from the application like any installed package.
npm install -g yalc
# in the library, after building
cd ~/src/my-lib && npm run build && yalc publish
# in the application
cd ~/src/app && yalc add my-lib && npm install
# after each library change
cd ~/src/my-lib && npm run build && yalc push # updates every app that added it
# when finished
cd ~/src/app && yalc remove my-lib && npm install
yalc add writes a file:.yalc/my-lib dependency into package.json and adds a yalc.lock. Do not commit those changes; add .yalc and yalc.lock to .gitignore.
Option 3: install the packed tarball
For a one-off check of "does the next release work in this app", nothing is more faithful than the real tarball:
cd ~/src/my-lib && npm pack
cd ~/src/app && npm install ~/src/my-lib/my-lib-2.4.0.tgz
This is the same technique as Smoke-Testing a Tarball with npm pack, applied to a real application.
Option 4: put them in one workspace
If you routinely change the library and the application together, they belong in the same repository. A pnpm, npm or Yarn workspace links them through the workspace protocol with a single copy of every shared dependency, and task runners rebuild the library when it changes. See Workspace Configuration Deep Dive.
TypeScript, watch mode and editor behaviour
Linked and copied packages interact with tooling in ways that are easy to misread as bugs in your code.
TypeScript resolution. With a symlinked library, TypeScript follows the symlink to the real path by default (preserveSymlinks: false), so it reads the library's declarations and resolves their imports — such as @types/react — from the library's own node_modules. That is where the "two ReactNode types" error comes from. Setting preserveSymlinks: true in the application's tsconfig.json makes TypeScript resolve from the symlink location instead, which usually fixes duplicate types at the cost of diverging from Node.js's runtime behaviour. With yalc or a tarball, the library lives inside the application's node_modules, and resolution behaves exactly like a published install.
Watch mode. Dev servers watch files inside the project. A symlink that points outside the project may not be watched, or may be excluded by default ignore patterns for node_modules. Vite needs the linked folder allowed in server.fs.allow and often benefits from excluding the linked package from dependency pre-bundling (optimizeDeps.exclude) so changes are picked up. With yalc, run the library's build in watch mode and yalc push after each build; the application's dev server sees a changed file in node_modules and reloads.
Editor go-to-definition. A linked library resolves to your source tree, so editor navigation lands in your working files, which is convenient while developing. A yalc copy or tarball lands in node_modules; ship declaration maps and sources if you want navigation to reach real code, as described in Publishing Declaration Maps for Go-to-Definition.
Cleaning up after linking
Links outlive the task that created them, and stale links are a steady source of "works on my machine" confusion. npm ls --link=true lists linked packages in a project; npm ls -g --depth=0 --link=true lists global links. Remove an application's link with npm unlink my-lib followed by npm install so the registry version is restored, and remove the global link with npm unlink -g my-lib. For yalc, yalc remove --all in the application and yalc installations clean tidy up.
pnpm and Yarn equivalents
pnpm has pnpm link --global and pnpm link <dir>, with the same symlink semantics and the same duplicate-peer risk; in a pnpm workspace, pnpm add my-lib@workspace:* is almost always the better choice. pnpm also supports "my-lib": "link:../my-lib" (symlink) and "my-lib": "file:../my-lib" (copy, with injected behaviour available through dependenciesMeta), where file: installs a hard-linked copy that resolves peers from the consumer. Yarn Berry offers link: and portal: protocols: portal: follows the linked package's dependencies, link: does not, which matters for the duplicate-dependency problem.
Worked example: a component library and three apps
A design system team maintains @acme/ui in its own repository and tests changes in three product applications. With npm link, one application worked, one showed invalid hook calls, and the third, using webpack 5 with symlink resolution disabled, did not pick up changes at all. The team switched to yalc: yalc publish in the library's postbuild script and yalc push --changed in watch mode updated all three applications with a real copy of the packed files, and the duplicate-React problem disappeared because each application resolved React from its own tree. Longer term, the team moved the three most tightly coupled applications into the same monorepo, keeping yalc only for the external consumers.
Prevention and guardrails
- Never commit link state. Add
.yalc/andyalc.lockto.gitignore, and checkpackage.jsonforfile:orlink:dependencies in CI. - Unlink when done.
npm unlink my-libin the app, thennpm install, restores the registry version; stale links cause confusing bugs weeks later. - Declare shared frameworks as peers in the library, so consumers' copies are used once installed normally.
- Validate releases with a tarball, whatever you use during development.
Frequently Asked Questions
Why does npm link work for some packages and not others? It works for packages without shared runtime state or peers. Libraries that depend on React, Vue, a GraphQL client or any singleton break because the linked copy resolves its own instance.
Does yalc need to be installed in CI? No. yalc is a local development tool. CI should install published versions or workspace packages, never yalc copies.
Is file: better than link:?
file: copies the package into node_modules, so it resolves dependencies from the consumer like a normal install, avoiding duplicates. link: creates a symlink with the same duplicate risk as npm link. With npm, file: pointing at a directory creates a symlink, so pnpm or yalc are needed for true copy semantics.
Related
- Testing and Validating Packages Before Publishing covers release-time validation.
- Deduplicating Duplicate React Versions fixes the duplicate-copy problem in depth.
- Workspace Symlinks vs Hard Links explains how symlinked packages resolve their dependencies.
- Converting Shared Code into Internal Workspace Packages is the long-term alternative to linking across repositories.