[Bug]: nextjs-vite builds a next-image virtual module from an unresolved alias, failing with "unexpected NUL byte" on Vite >= 8.1
Describe the bug
When an image is imported through a tsconfig.json paths alias from a file that is outside the tsconfig project (excluded by exclude, or not matched by include), storybook build fails on Vite >= 8.1.0 with an UNLOADABLE_DEPENDENCY / "unexpected NUL byte" error. The same project builds on Vite <= 8.0.16.
The message points at the import site and says no plugin handled the virtual module, which makes it look like a bundler fault. The actual cause is in vite-plugin-storybook-nextjs: its next-image plugin creates a virtual module id out of a specifier it has already failed to resolve, and its load hook then swallows the resulting ENOENT.
Error
Immediately before the failure the plugin prints:
Could not read image file @/assets/pic.webp: Error: ENOENT: no such file or directory, open '@/assets/pic.webp'
errno: -2,
code: 'ENOENT',
syscall: 'open',
path: '@/assets/pic.webp'
and then the build fails with:
[UNLOADABLE_DEPENDENCY] Could not load
\0virtual:next-image:QC9hc3NldHMvcGljLndlYnA
╭─[ src/Demo.stories.tsx:1:17 ]
│
1 │ import pic from "@/assets/pic.webp";
│ ─────────┬─────────
│ ╰─────────── file name contained an unexpected NUL byte
│
│ Help: This module seems to be a virtual module, but no plugin handled it via the load hook.
───╯
QC9hc3NldHMvcGljLndlYnA is base64url for @/assets/pic.webp — the raw, unresolved specifier.
Version matrix
Storybook 10.6.0, Next 16.3.4, Node 24.16.0, pnpm 10.33.0, on a Next app whose tsconfig.json excludes **/*.stories.tsx:
| vite | rolldown | storybook build |
|---|---|---|
| 8.0.8 | 1.0.0-rc.15 | passes |
| 8.0.16 | 1.0.3 | passes |
| 8.1.0 | 1.1.5 (~1.1.2) |
fails |
| 8.3.0 | 1.2.8 (~1.2.6) |
fails |
Each row was installed and built; the boundary is Vite 8.0.16 → 8.1.0, whose only relevant change is the rolldown bump from 1.0.3 to 1.1.x.
Analysis
Two independent things combine.
1. Vite 8.1 narrowed resolve.tsconfigPaths to the tsconfig's own file scope. On 8.0.x, paths mappings were applied to any importer. From 8.1.0 the tsconfig include/exclude are honoured, so an importer excluded from the project no longer gets paths applied. This looks deliberate — it matches what vite-tsconfig-paths has always done and what tsc implies — and I am not reporting it as a Vite bug. It is simply the trigger.
The effect is observable from inside a plugin hook at Vite 8.3.0, with resolve.tsconfigPaths: true (which vite-plugin-storybook-nextjs sets for Vite 8+):
this.resolve('@/components/Button/Button', <a *.stories.tsx importer>) => null
this.resolve('@/components/Button/Button', <a non-excluded importer>) => /abs/path/Button.tsx
this.resolve('./MainPage', <a *.stories.tsx importer>) => /abs/path/MainPage.tsx
2. The next-image plugin treats a failed resolve as success. In plugin.ts#L121-L136, when this.resolve() returns null and the require.resolve() fallback throws, imagePath is left as the original specifier:
const resolvedByVite = await this.resolve(source, importer, { skipSelf: true });
if (resolvedByVite?.id) {
imagePath = resolvedByVite.id.split('?')[0];
} else {
try {
imagePath = require.resolve(source, { paths: [path.dirname(importerPath)] });
} catch {
imagePath = source;
}
}
resolveId then returns ${virtualImagePrefix}${encodeBase64Url(imagePath)} regardless — so the module id now claims ownership of a path that cannot exist on disk.
The load hook decodes it, fs.promises.readFile raises ENOENT, and the catch logs and returns undefined:
} catch (err) {
console.error(`Could not read image file ${imagePath}:`, err);
return undefined;
}
undefined means "not handled" to the bundler, so rolldown falls back to reading the id as a file path and reports the NUL byte. The real error — that @/assets/pic.webp could not be resolved — is only a console.error line, and the failure that stops the build names neither the plugin nor the unresolved alias.
Note that the relative-import branch (source.startsWith('.')) is unaffected, so in a mixed codebase only the aliased image imports fail.
This is the same failure shape as #32355 (there the path is mangled by Windows separators rather than left unresolved): in both cases a virtual id is minted around a path the load hook then cannot read.
Suggested fix
Two changes, either of which turns the failure into a clear one, and the first of which avoids it entirely for this case:
- In
resolveId, returnnullwhen the specifier could not be resolved, instead of falling back toimagePath = source. Normal resolution then runs and reports an ordinary unresolved-import error naming the alias. - In
load,throw(orthis.error(...)) instead ofreturn undefined, so a virtual id this plugin created is never handed back to the bundler unresolved.
Reproduction
Verified end to end in a scratch directory — 7 files, no framework generator needed. npm install, then npx storybook build.
package.json
{
"name": "sb-next-image-alias-repro",
"private": true,
"version": "0.0.0",
"scripts": { "build-storybook": "storybook build" },
"dependencies": { "next": "16.3.4", "react": "19.2.5", "react-dom": "19.2.5" },
"devDependencies": {
"@storybook/nextjs-vite": "10.6.0",
"storybook": "10.6.0",
"typescript": "5.8.3",
"vite": "8.3.0"
}
}
tsconfig.json — note the exclude, which is what puts the story file outside the project:
{
"compilerOptions": {
"jsx": "preserve",
"module": "esnext",
"moduleResolution": "bundler",
"target": "es2020",
"strict": false,
"noEmit": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "**/*.stories.tsx"]
}
next.config.js
module.exports = {}
.storybook/main.js
export default {
stories: ['../src/**/*.stories.tsx'],
framework: { name: '@storybook/nextjs-vite', options: {} },
core: { disableTelemetry: true },
}
src/Demo.tsx
import Image from 'next/image'
export function Demo({ src }: { src: any }) {
return <Image src={src} alt="demo" />
}
src/Demo.stories.tsx
import pic from '@/assets/pic.webp'
import { Demo } from './Demo'
export default { component: Demo }
export const Default = { args: { src: pic } }
src/assets/pic.webp — any image file will do; a 1×1 webp is enough:
mkdir -p src/assets
printf 'UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==' | base64 -d > src/assets/pic.webp
Both controls were run against this same project and confirm the two conditions:
npm install [email protected] && npx storybook build→ build completed successfully- remove
"**/*.stories.tsx"fromexclude, keep vite 8.3.0 → build completed successfully - restore the
excludeon vite 8.3.0 → fails again as above
Additional context
@storybook/[email protected] and [email protected] declare "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", so Vite 8.1+ installs as an in-range peer.
System
System:
OS: Linux 6.1.0-53-amd64 (Debian, x64)
Binaries:
Node: 24.16.0
npm: 11.x
pnpm: 10.33.0
npmPackages:
@storybook/nextjs-vite: 10.6.0
storybook: 10.6.0
next: 16.3.4
vite: 8.0.8 / 8.0.16 / 8.1.0 / 8.3.0 (see matrix)
Source: storybookjs/storybook