published: false
tags: vercel, nitro, tanstack, debugging
Production went down twice in one day with the same error, and both times the branch had built and run perfectly on its own preview deployment. Here's what it was, why previews don't catch it, and the check that now stops it reaching production.
The symptom
Every route returns a 500. Not a slow route, not one endpoint — the whole site. The runtime log says:
TypeError: __exportAll is not a function
at file:///var/task/_ssr/server-ATlsWYy_2.mjs:1916:38
at ModuleJob.run (node:internal/modules/esm/module_job:439:25)
at async Object.fetch (file:///var/task/_ssr/ssr.mjs:177:56)
The stack has no application code in it. It fails at module load, before any handler runs, which is why nothing is spared.
The stack is also unhelpful in a specific way: server-ATlsWYy_2.mjs is a generated chunk name that changes on every build, and line 1916 is in code you never wrote. There is nothing to grep for in your own source.
What __exportAll actually is
It's a bundler helper. When a bundler needs to hand you a module namespace object — the thing you get from import * as ns or from await import('./thing') — it generates a small function to copy the exports across. Different bundlers name it differently; in this output it's __exportAll.
So the helper's presence means: somewhere, something asked for a whole module as an object rather than for named bindings.
The cause
A dynamic import() of a module that is also imported statically somewhere else in the same graph.
// at the top of the file
import { buildClaimEmail, verifyClaimToken } from './purchase-claims.server'
// ...and then, further down, inside a function
const helpers = await import('./purchase-claims.server')
That dynamic import buys nothing. The module is already in the bundle, already loaded, already reachable through the static import. But it forces the bundler to construct a namespace object for it anyway, and the helper that builds that object gets placed in whichever chunk the bundler thinks is shared.
On Vercel, Nitro runs a second bundling pass over the already-bundled server output to produce the Lambda. That pass re-chunks. If the helper and the code calling it end up split across two chunks that import each other — in our case server-XXXX.mjs and its split twin server-XXXX2.mjs — you get a circular ESM import where one side reads the helper before the other side has finished defining it. At module-evaluation time the binding is still in its temporal dead zone, so the call site sees undefined.
Hence: __exportAll is not a function.
Why the preview build passes
This is the part that cost us the second outage.
The branch's own preview deployment builds the branch tip. That build is fine. The crash appears only after merging, because the merge commit contains a different set of modules, which changes how the bundler groups chunks, which is what puts the helper on the wrong side of a cycle.
So a green preview tells you nothing about whether the merge will boot. The artifact that breaks is one that nobody built until it was already in production.
The fix
Delete the dynamic import. Use the static one that was already there.
import { buildClaimEmail, verifyClaimToken } from './purchase-claims.server'
That's the whole fix. The pattern has a name worth knowing — an ineffective dynamic import: a lazy import of something that isn't lazy, because it's eagerly imported elsewhere in the same bundle. It gives you none of the code-splitting benefit and all of the namespace-object cost.
The check that stops it recurring
Two layers, both running at the end of the build.
Source scan. Walk the source tree and report any module that is imported dynamically in one place and statically in another. Exclude type-only imports, since TypeScript erases those. This catches the root cause in milliseconds and points at both call sites with line numbers, which is far more actionable than a generated chunk name.
Output scan. Read the built SSR output as an import graph and fail if a chunk that defines or imports the helper sits on an import cycle.
That second layer needs calibrating, and this is the part I'd have got wrong by guessing: the mere presence of __exportAll in an app chunk is not the failure. A perfectly healthy build also constructs namespace objects — the framework does it for its own router and server entries. If you fail the build on "helper found", you'll fail every build.
The actual crash signature is the cycle. We verified that by building both real commits with the production preset: the healthy one passes, the crashed one fails and names the offending module.
Making CI test the merge result
Since the branch tip isn't what breaks, the check has to run against the merge commit.
GitHub Actions gives you this for free on the pull_request event: actions/checkout checks out refs/pull/N/merge, which is the merge result rather than the branch tip. Build that, with the same preset production uses, and the check is examining the artifact that will actually deploy.
Two repository settings make it stick, and neither is the default:
• Mark the job as a required status check.
• Enable Require branches to be up to date before merging, so the merge commit the job built is the one that lands.
Without the second, you can pass the check on a stale merge base and still ship something nobody built.
The general lesson
The specific bug is narrow — one framework, one host, one bundler pass. But two things generalise:
A green preview is not a green deploy when your build output depends on the full module graph. Anything that re-chunks at deploy time has this property.
Verify a guard by making it fire. We reintroduced the bad import deliberately on a scratch commit to confirm the build failed with a useful message, then removed it. A guard that has never failed is a guard you're only assuming works.
Top comments (0)