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.

Mttao Mttao @mttao 1,230 words 中文 →

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_MODULE is the package that could not be resolved
  • SOME_FILE is 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 doesWhere it should live
Read files, scan directories, call node:fsServer or build time
Generate highlighted Markdown HTMLUsually at build time
Clicks, inputs, dialogs, and other UIReact 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.

SettingValue
Framework presetAstro
Install commandpnpm install --frozen-lockfile
Build commandpnpm run build
Build output directorydist
Node.js version22

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

  1. Save the full log. Read the failed to resolve import line. Do not stop at the external hint.

  2. Reproduce it with a clean local install:

    pnpm install --frozen-lockfile
    pnpm run build
  3. Check the missing package. If your code imports it directly, add it to dependencies.

  4. Check the importing file. If it is a React client component, make sure it is not reading files or calling Node.js APIs.

  5. If the error mentions WASM or a platform-specific package, check wasm32. The browser may need a different package than the Linux builder.

  6. Consider external last, 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

Mttao GitHub ↗

Exploring technology and life's wisdom

Related Posts

View all →
  1. 01 Meet EmDash: Cloudflare’s Next-Generation AI-Native CMS Cloudflare· Jul 20, 2026
  2. 02 Build Cloudflare Workers with Rust: A Practical workers-rs Guide cloudflare· Jul 30, 2026
  3. 03 Building Your First Stateful Serverless Application with Cloudflare Durable Objects from Scratch cloudflare· Oct 16, 2025

/ Comments