cloudflare · · 6 min read
Cloudflare Pages Build Failed: Don’t Rush to Add external
When a Cloudflare Pages build fails, the log may tell you to add a module to build.rolldownOptions.external. The real issue was undeclared dependencies and a missing browser WASM package, not a missing Vite config.
I deployed an Astro site to Cloudflare Pages and the build failed.
pnpm run build worked on my machine. The same commit failed on Pages. The last line in the log was:
If you do want to externalize this module explicitly add it to
`build.rolldownOptions.external`
That sentence is easy to misread. It sounds like the fix is a few lines of external in astro.config.ts.
That was not the fix. external is only a fallback the bundler mentions. The real problem was simpler: the bundler could not find a package, and that package was being pulled into browser code.
Here is how I traced it, and what I changed.
Read the full error, not just the last line
Scroll up. You will usually see something like this:
[vite]: Rolldown failed to resolve import "SOME_MODULE" from "SOME_FILE".
This is most likely unintended because it can break your application at runtime.
If you do want to externalize this module explicitly add it to
`build.rolldownOptions.external`
The first line is the one that matters:
SOME_MODULEis the package that could not be resolvedSOME_FILEis the file that imported it
The external hint means: if you are sure this package should not be bundled into the current graph, you can exclude it. Vite does expose build.rolldownOptions for the underlying bundler. That is not a universal switch for “module not found.”
Find the missing package first. Decide how to fix it second. Do not start by adding external.
In this case the stack mentioned @vitejs/plugin-react. That pointed me at React components marked with client:load. The first question became: did server-only code get pulled into a browser bundle by accident?
Why it worked locally and failed on Cloudflare Pages
My local machine had been using npm for a while. Cloudflare Pages ran a clean install with pnpm.
The two package managers lay out node_modules differently. npm often hoists transitive dependencies to the top level. A file can import a package the project never declared, and the local install still finds it.
pnpm does not do that by default. The project root only gets the packages you declared. If your code imports a package that is missing from package.json, the clean CI install fails immediately.
Take this import:
import { codeToHtml } from 'shiki'
If shiki is not in this project’s dependencies, a local build that still works is luck. Astro, a plugin, or an old node_modules tree may have leaked it in. On Pages, the environment is clean, the package is not installed, and the build stops.
The first fix is straightforward: if your source imports a package, put it in your own package.json.
pnpm add shiki
Cloudflare Pages did not break the project. It just found the incomplete dependency list sooner than my laptop did.
Then the browser needed a WASM package
After the direct dependency was in place, I kept reading the error source. This time it pointed at Sätteri, a Markdown highlighter.
On a server, Sätteri usually uses a native binary for Linux, macOS, or Windows. Once that code is bundled for the browser, those native files are useless. The browser needs the WASM build instead. Think of WASM as a compiled artifact the browser can actually run.
The browser build lives in a separate package: @bruits/satteri-wasm32-wasi. It is an optional dependency. The package manager decides whether to install it based on the current machine.
That is the trap. Cloudflare’s build image is Linux. pnpm installs what Linux needs right now. The browser needs the wasm32 package, so it never gets downloaded. When a React component pulls highlighting code into the client bundle, Rolldown cannot resolve the module and you get the same “failed to resolve import” error again.
Cloudflare was not missing a system library. The project never told pnpm to fetch the WASM package the browser needs, in addition to the host platform packages.
If you really need Sätteri in the browser, create or update pnpm-workspace.yaml at the project root:
supportedArchitectures:
os:
- current
cpu:
- current
- wasm32
With wasm32 listed, pnpm also installs the WASM package prepared for the browser. Sätteri’s installation docs show the same setting.
A better question: should this code run in the browser at all?
At this point I still needed to decide where Markdown highlighting belongs: in the browser, or at build time.
For a static site, the browser usually does not need to highlight anything again. File reads, directory scans, and highlighting belong in .astro files or server modules. A React component with client:load should only handle interaction that actually happens in the browser.
This table is enough for most cases:
| What the code does | Where it should live |
|---|---|
Read files, scan directories, call node:fs | Server or build time |
| Generate highlighted Markdown HTML | Usually at build time |
| Clicks, inputs, dialogs, and other UI | React client components |
If client code accidentally imports node:fs or node:path, do not paper over it with external. The browser does not have those APIs. Move the logic back to the server, or split it into two files.
What I changed
I worked through the problem in this order.
1. Pin pnpm and the build command
Declare the pnpm version in package.json:
{
"packageManager": "[email protected]",
"engines": {
"node": ">=22",
"pnpm": ">=10"
},
"scripts": {
"build": "astro build"
}
}
These Cloudflare Pages settings are enough. Astro writes the site to dist.
| Setting | Value |
|---|---|
| Framework preset | Astro |
| Install command | pnpm install --frozen-lockfile |
| Build command | pnpm run build |
| Build output directory | dist |
| Node.js version | 22 |
Keep one lockfile. If the project uses pnpm, keep pnpm-lock.yaml and delete package-lock.json. Otherwise local installs and CI can resolve different versions.
2. Declare every package the project imports
Packages your source imports directly, such as shiki, belong in dependencies. Do not rely on them happening to be a plugin’s transitive dependency.
3. Tell pnpm to install wasm32 when the browser needs WASM
If that code must run in the browser, add wasm32 in pnpm-workspace.yaml as shown above. If it does not need to run in the browser, move it out of the React client component.
4. Leave external for last
external is appropriate in one case: you have already confirmed the package should not enter the current bundle.
A Node.js utility imported from client code is an example. You can keep external as a guard after you fix the import graph:
// astro.config.ts
export default defineConfig({
vite: {
build: {
rolldownOptions: {
external: [/^node:/],
},
},
},
})
If you externalize a package the browser actually needs, the build may pass and the page will still fail at runtime. external is not a fix for a missing package.
Next time this error shows up
-
Save the full log. Read the
failed to resolve importline. Do not stop at theexternalhint. -
Reproduce it with a clean local install:
pnpm install --frozen-lockfile pnpm run build -
Check the missing package. If your code imports it directly, add it to
dependencies. -
Check the importing file. If it is a React client component, make sure it is not reading files or calling Node.js APIs.
-
If the error mentions WASM or a platform-specific package, check
wasm32. The browser may need a different package than the Linux builder. -
Consider
externallast, and only if that package should never be bundled for the browser.
Takeaway
The Cloudflare Pages failure looked like a missing Vite setting. It was two ordinary problems: the project did not declare a package it imported, and the WASM package the browser needed was never installed.
The next time you see build.rolldownOptions.external, leave the config alone until you can answer three questions: which package is missing, who imports it, and should that code run in the browser.
Mttao GitHub ↗
Exploring technology and life's wisdom
Related Posts
View all →- 01 Meet EmDash: Cloudflare’s Next-Generation AI-Native CMS Cloudflare· Jul 20, 2026
- 02 Build Cloudflare Workers with Rust: A Practical workers-rs Guide cloudflare· Jul 30, 2026
- 03 Building Your First Stateful Serverless Application with Cloudflare Durable Objects from Scratch cloudflare· Oct 16, 2025