prerender fails when the server build emits a filename other than `<serverEntryBasename>.js`
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 15.1k
- Forks
- 1.9k
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 143
Description
Description
The prerender pass starts a Vite preview server and fetches each configured page over HTTP. The preview server's SSR fallback middleware locates the built server module by reconstructing its filename from the server input name and appending a hardcoded .js, rather than using the filename the build actually emitted.
Any build whose server output is not literally <inputBasename>.js therefore fails: the dynamic import() throws ERR_MODULE_NOT_FOUND, the middleware forwards the error, every prerender fetch gets a 500, and the build aborts.
Root cause
packages/start-plugin-core/src/vite/preview-server-plugin/plugin.ts:
const serverInput = getBundlerOptions(serverEnv?.build)?.input ?? 'server'
if (typeof serverInput !== 'string') {
throw new Error('Invalid server input. Expected a string.')
}
// Get basename without extension and add .js
const outputFilename = `${basename(serverInput, extname(serverInput))}.js`
const serverOutputDir = getServerOutputDirectory(server.config)
const serverEntryPath = join(serverOutputDir, outputFilename)
const imported = await import(pathToFileURL(serverEntryPath).toString())
outputFilename is derived from the input and pinned to .js. It ignores what the server environment actually wrote — e.g. a configured build.rollupOptions.output.entryFileNames, or the output naming of a builder plugin that produces the server bundle.
Reproduction
Two ways into the same code path.
A — framework only, no third-party builder. A TanStack Start app with pages + prerender.enabled configured, plus a server-environment output name that differs from the input basename:
// vite.config.ts
export default defineConfig({
environments: {
server: {
build: { rollupOptions: { output: { entryFileNames: 'index.mjs' } } },
},
},
plugins: [
tanstackStart({
pages: [{ path: '/about' }],
prerender: { enabled: true },
}),
// ...
],
})
The server bundle is emitted as dist/server/index.mjs, but the preview server tries to import dist/server/server.js.
B — the real-world case we hit. A Cloudflare-targeted Nitro build (nitro/vite, cloudflare-module preset) emits dist/server/index.mjs plus dist/server/wrangler.json. Same mismatch, same failure.
Expected
Prerender resolves and boots whatever server entry the build emitted, or fails with a message that names the file it looked for and the files that are actually present.
Actual
ERR_MODULE_NOT_FOUND <root>/dist/server/server.js
Failed to fetch /about: Internal Server Error
at .../start-plugin-core/dist/esm/prerender.js:81
Build exits 1. The underlying ERR_MODULE_NOT_FOUND is not surfaced in the thrown error — the failure presents only as a 500 from the preview server, which makes it hard to diagnose. prerender.ts throws on !res.ok with res.statusText, so the real cause is swallowed.
Versions
Reproduced with @tanstack/start-plugin-core 1.171.17 and 1.171.36 — the code is identical in both, so this is long-standing rather than a recent regression. Vite 8.
Suggested fix
Resolve the emitted entry instead of reconstructing its name. Options, roughly in order of preference:
- Read the real filename from the build result. The server environment's Rollup output bundle already knows the emitted entry chunk's
fileName; using it removes the guess entirely. - Resolve rather than assume the extension.
exsolveis already a dependency of this package, so probing.js/.mjs/.cjs(or a plainresolveModulePath) aroundjoin(serverOutputDir, basename(serverInput))would be a small, dependency-free change. - Let the host declare it — an option such as
prerender.serverEntry(or reusingserver.entry's resolved output) for builds whose output naming the plugin can't infer.
Independently useful: include the caught ERR_MODULE_NOT_FOUND (and a directory listing of serverOutputDir) in the surfaced error, so this presents as "couldn't find the server bundle" instead of an opaque 500.
Secondary, related
The same middleware calls the imported handler with a single argument:
const webRes: Response = await serverBuild.fetch(webReq)
A Cloudflare-style module export has the signature fetch(request, env, ctx), so env and ctx are undefined under prerender even when the server entry is found. Any handler that dereferences env (a binding, an asset fetcher) throws. Passing at least an empty env and a stub ctx would make Cloudflare-targeted server outputs prerenderable. Happy to split this into its own issue if you'd prefer to keep them separate.
Workaround for anyone else hitting this
Generate the file the middleware expects, immediately before the prerender pass, as a thin adapter over the real output, and remove it afterwards. Note two details: supply env/ctx yourself per the point above, and if your handler runs through srvx, shadow req.ip with an own writable property — the Nitro Cloudflare handler assigns to it and it's getter-only on NodeRequest.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with packages/start-plugin-core/src/vite/preview-server-plugin/plugin.ts and reproduce the issue using a server output such as index.mjs instead of server.js. Read prerender.ts to follow how the preview response becomes an error. Done means prerender imports the emitted server entry and surfaces a useful missing-entry error rather than only returning a 500.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript, vite
- Domain
- backend, build-system
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100