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.
exsolve is already a dependency of this package, so probing .js / .mjs / .cjs (or a plain resolveModulePath) around join(serverOutputDir, basename(serverInput)) would be a small, dependency-free change.
- Let the host declare it — an option such as
prerender.serverEntry (or reusing server.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.
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>.jstherefore fails: the dynamicimport()throwsERR_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:outputFilenameis derived from the input and pinned to.js. It ignores what the server environment actually wrote — e.g. a configuredbuild.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.enabledconfigured, plus a server-environment output name that differs from the input basename:The server bundle is emitted as
dist/server/index.mjs, but the preview server tries to importdist/server/server.js.B — the real-world case we hit. A Cloudflare-targeted Nitro build (
nitro/vite,cloudflare-modulepreset) emitsdist/server/index.mjsplusdist/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
Build exits 1. The underlying
ERR_MODULE_NOT_FOUNDis not surfaced in the thrown error — the failure presents only as a 500 from the preview server, which makes it hard to diagnose.prerender.tsthrows on!res.okwithres.statusText, so the real cause is swallowed.Versions
Reproduced with
@tanstack/start-plugin-core1.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:
fileName; using it removes the guess entirely.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.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 ofserverOutputDir) 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:
A Cloudflare-style module export has the signature
fetch(request, env, ctx), soenvandctxareundefinedunder prerender even when the server entry is found. Any handler that dereferencesenv(a binding, an asset fetcher) throws. Passing at least an emptyenvand a stubctxwould 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/ctxyourself per the point above, and if your handler runs throughsrvx, shadowreq.ipwith an own writable property — the Nitro Cloudflare handler assigns to it and it's getter-only onNodeRequest.