Repository: vercel/next.js
Pull request: fix: Use a pnpm preinstall script to fix catch-22 in next binary creation

We've had a longstanding issue where you'd have to run `pnpm i` _twice_ on a fresh checkout because `pnpm` wouldn't set up binary symlinks until after the binary exists, but the binary wouldn't exist until after `pnpm build` was done:

https://github.com/vercel/next.js/blob/canary/.github/workflows/build_reusable.yml#L303-L311

This solves that problem using a pnpm preinstall script that creates stub binaries for these files if they don't exist yet (because we haven't run `pnpm build`).

## Test Plan

```
git clean -fxd
pnpm i
pnpm build
pnpm test-start test/e2e/app-dir/next-image/
pnpm test-start-turbo test/e2e/app-dir/next-image/
```

I tested this both locally and inside of a fresh ubuntu VM.

Running `pnpm next` after `pnpm i` but before `pnpm build` gives a helpful error message:

<img width="946" height="125" alt="Screenshot 2026-03-06 at 9 37 14 PM" src="https://github.com/user-attachments/assets/4517e8bd-60ca-44f2-a34c-f7f3ec56c634" />


Changed files:
filename: .github/workflows/build_reusable.yml
patch: @@ -300,16 +300,6 @@ jobs:
       - run: ANALYZE=1 pnpm build
         if: ${{ inputs.skipInstallBuild != 'yes' }}
 
-      # Some packages e.g. `devlow-bench` depend on `pnpm build` to generate
-      # their `dist` directory. The first run of `pnpm install` will generate
-      # warnings because these don't exist yet.
-      #
-      # We need to run `pnpm install` a _second_ time to fix this. Fortunately,
-      # this second run is very fast and cheap.
-      - name: Re-run pnpm install to link built packages into node_modules/.bin
-        run: pnpm install
-        if: ${{ inputs.skipInstallBuild != 'yes' }}
-
       - run: pnpm playwright install --with-deps ${{ inputs.browser }}
         if: ${{ inputs.skipInstallBuild != 'yes' }}
 

filename: package.json
patch: @@ -94,6 +94,7 @@
     "clean-trace-jaeger": "node scripts/rm.mjs test/integration/basic/.next && TRACE_TARGET=JAEGER pnpm next build test/integration/basic",
     "debug": "cross-env NEXT_PRIVATE_LOCAL_DEV=1 NEXT_TELEMETRY_DISABLED=1 node --inspect --trace-deprecation --enable-source-maps packages/next/dist/bin/next",
     "debug-brk": "cross-env NEXT_PRIVATE_LOCAL_DEV=1 NEXT_TELEMETRY_DISABLED=1 node --inspect-brk --trace-deprecation --enable-source-maps packages/next/dist/bin/next",
+    "pnpm:devPreinstall": "node scripts/create-next-bin-placeholder.mjs",
     "postinstall": "node scripts/git-configure.mjs && node scripts/install-native.mjs",
     "version": "pnpm install --no-frozen-lockfile && git add .",
     "prepare": "husky",

filename: scripts/create-next-bin-placeholder.mjs
patch: @@ -0,0 +1,42 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const scriptDir = path.dirname(fileURLToPath(import.meta.url))
+const repoRoot = path.join(scriptDir, '..')
+
+const placeholders = [
+  path.join(repoRoot, 'packages', 'next', 'dist', 'bin', 'next'),
+  path.join(repoRoot, 'packages', 'create-next-app', 'dist', 'index.js'),
+  path.join(
+    repoRoot,
+    'turbopack',
+    'packages',
+    'devlow-bench',
+    'dist',
+    'cli.js'
+  ),
+]
+
+for (const binPath of placeholders) {
+  if (fs.existsSync(binPath)) {
+    continue
+  }
+
+  fs.mkdirSync(path.dirname(binPath), { recursive: true })
+
+  fs.writeFileSync(
+    binPath,
+    `#!/usr/bin/env node
+console.error(
+  "Local workspace has not been built yet. Run 'pnpm build' first."
+)
+process.exit(1)
+`,
+    'utf8'
+  )
+
+  if (process.platform !== 'win32') {
+    fs.chmodSync(binPath, 0o755)
+  }
+}

filename: scripts/install-native.mjs
patch: @@ -60,7 +60,12 @@ import fsp from 'fs/promises'
     fs.writeFileSync(path.join(tmpdir, 'package.json'), JSON.stringify(pkgJson))
     fs.writeFileSync(path.join(tmpdir, '.npmrc'), 'node-linker=hoisted')
 
-    const args = ['add', `next@${nextVersion}`]
+    const args = [
+      'add',
+      `next@${nextVersion}`,
+      '--lockfile=false',
+      '--ignore-workspace',
+    ]
     if (preferOffline) {
       args.push('--prefer-offline')
     }