Skip to content

sqliteConnector: 'bun' fails during prerender when building on Node.js for Bun deployment #3829

Description

@gepotumu

Environment

Environment

Item Version
Node.js (builder) 24.4
Bun (runtime) latest (alpine)
Nuxt 4.x
@nuxt/content 3.15.1 (3.14.0 also affected)
Nitro preset bun
Build command nuxt build --preset bun (executed in Node.js)
Deployment oven/bun:alpine container

Version

3.15.1

Reproduction

` ERROR Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. Received protocol 'bun:'

at throwIfUnsupportedURLScheme (node:internal/modules/esm/load:209:11)
at defaultLoad (node:internal/modules/esm/load:107:3)
at ModuleLoader.load (node:internal/modules/esm/loader:800:12)
at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:580:43)
at #createModuleJob (node:internal/modules/esm/loader:604:36)
at #getJobFromResolveResult (node:internal/modules/esm/loader:338:34)
at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:306:41) `

Description

Description

Scenario

A Dockerfile uses node:24.4-alpine as the builder image (Node.js environment), runs nuxt build --preset bun, then deploys the output to an oven/bun:alpine runtime container.

Original error (@nuxt/content 3.14.0, no extra config)

At runtime, Bun cannot load the better-sqlite3 native binding:

'better-sqlite3' is not yet supported in Bun.
Track the status in https://github.com/oven-sh/bun/issues/4290
In the meantime, you could try bun:sqlite which has a similar API.
    at dlopen (unknown)
    at bindings (/app/.output/server/node_modules/bindings/bindings.js:112:48)
    at new Database (/app/.output/server/node_modules/better-sqlite3/lib/database.js:48:29)
    at getDB (/app/.output/server/index.mjs:59792:13)

Cause: process.versions.bun is absent during the Node.js build, so findBestSqliteAdapter() falls back to better-sqlite3 and bundles it into the server output. Bun cannot use better-sqlite3 at runtime.

Attempted fix 1: Upgrade to 3.15.1 + sqliteConnector: 'bun'

Following nuxt/content#3741 (merged in v3.15.0):

// nuxt.config.ts
export default defineNuxtConfig({
  content: {
    experimental: {
      sqliteConnector: 'bun',
    },
  },
})

Result: Client and Server builds succeed, but the prerender stage fails:

✔ Client built in 183765ms
✔ Server built in 32539ms
ℹ Initializing prerenderer

ERROR  Only URLs with a scheme in: file, data, and node are supported by the default ESM loader.
Received protocol 'bun:'

    at throwIfUnsupportedURLScheme (node:internal/modules/esm/load:209:11)
    at defaultLoad (node:internal/modules/esm/load:107:3)

Attempted fix 2: database.type = 'bunsqlite' + Nitro externals

Set content.database.type = 'bunsqlite' directly and add bun:sqlite to Nitro externals via nitro:config hook:

content: {
  database: {
    type: 'bunsqlite' as any,
    filename: './contents.sqlite',
  },
},
hooks: {
  'nitro:config'(nitroConfig) {
    nitroConfig.externals = nitroConfig.externals || {} as any
    nitroConfig.externals.external = nitroConfig.externals.external || []
    nitroConfig.externals.external.push('bun:sqlite')
  },
}

Result: Same prerender-stage error.

Root cause analysis

The issue lies in the interaction between Nitro's prerender flow and @nuxt/content's database.server.js.

1. Static top-level import in database.server.js

// @nuxt/content/dist/runtime/internal/database.server.js
import adapter from "#content/adapter";       // ← static top-level import
import localAdapter from "#content/local-adapter";

export default function loadDatabaseAdapter(config) {
  if (import.meta.dev || ["nitro-prerender", "nitro-dev"].includes(import.meta.preset)) {
    db = localAdapter(refineDatabaseConfig(localDatabase));  // prerender uses localAdapter
  } else {
    db = adapter(refineDatabaseConfig(database));            // production uses adapter
  }
}

Although the prerender path only uses localAdapter at runtime (line 13), adapter (i.e. #content/adapter, pointing to the bun-sqlite connector) is statically imported at the top level (line 6). Node.js's ESM loader attempts to resolve bun:sqlite during module loading, regardless of whether adapter is actually called.

2. findBestSqliteAdapter does not differentiate prerender

// @nuxt/content 3.15.x
async function findBestSqliteAdapter(opts) {
  if (opts.sqliteConnector === "bun") {
    if (!process.versions.bun) {
      console.warn("...");  // only warns, does NOT fall back
    }
    return opts.resolver.resolve("./runtime/internal/connectors/bun-sqlite");
    // ← always returns bun-sqlite regardless of build/runtime environment
  }
  // ...
}

3. @nuxt/content sets prerender: true on sql_dump routes

// @nuxt/content module.mjs
manifest.collections.forEach((collection) => {
  if (!collection.private) {
    const key = `/__nuxt_content/${collection.name}/sql_dump.txt`;
    nuxt.options.routeRules[key] = { ...nuxt.options.routeRules[key], prerender: true };
  }
});

Even if the user does not configure any prerender routes, @nuxt/content automatically adds routes that require prerendering, which triggers prerenderer initialization.

4. Nitro prerender execution chain

Nitro bundles server bundle  → bun:sqlite marked as external ✓
Nitro bundles prerender bundle → bun:sqlite marked as external ✓
Nitro executes prerender bundle in Node.js → import "bun:sqlite" → Node.js error ✗

Rollup-level external only solves the bundling phase resolve. It cannot solve the bun: protocol issue when Node.js actually executes the output during prerender.

Affected scope

All projects that meet the following conditions:

  • Build in Node.js (nuxt build --preset bun, or CI/CD using Node.js)
  • Deploy to Bun runtime
  • Use @nuxt/content module
  • Have any prerender routes (content module's own sql_dump.txt routes will trigger this)

Possible solutions

Approach 1: Use dynamic import for #content/adapter (recommended)

Change the static top-level import of #content/adapter in database.server.js to a lazy dynamic import, only loading it when actually needed in production (non-prerender):

// Before
import adapter from "#content/adapter";

// After
let _adapter;
async function getAdapter() {
  if (!_adapter) {
    _adapter = (await import("#content/adapter")).default;
  }
  return _adapter;
}

This way, the prerender stage will not trigger the bun:sqlite import, since prerender only uses localAdapter.

Pros: Minimal change, does not affect any other scenario.
Cons: Requires making the adapter usage path in loadDatabaseAdapter async.

Approach 2: Override #content/adapter alias for prerender builds

In @nuxt/content's nitro:config hook, detect cross-compilation and set #content/adapter to the same value as #content/local-adapter (i.e. a Node.js-compatible connector):

nuxt.hook('nitro:config', async (config) => {
  if (sqliteConnector === 'bun' && !process.versions.bun) {
    config.alias["#content/adapter"] = config.alias["#content/local-adapter"];
  }
});

Pros: No changes to database.server.js needed.
Cons: Requires careful hook execution ordering (must run after the alias is initially set). Also, the production #content/adapter alias in the main server bundle would also be overridden, so this approach would need to target only the prerender build somehow.

Approach 3: Skip content prerender routes during cross-compilation

In @nuxt/content's module setup, when sqliteConnector: 'bun' is set and the build environment is Node.js, do not set prerender: true on sql_dump.txt routes:

manifest.collections.forEach((collection) => {
  if (!collection.private) {
    const key = `/__nuxt_content/${collection.name}/sql_dump.txt`;
    const skipPrerender = sqliteConnector === 'bun' && !process.versions.bun;
    nuxt.options.routeRules[key] = {
      ...nuxt.options.routeRules[key],
      prerender: !skipPrerender,
    };
  }
});

Pros: Precisely scoped.
Cons: Only addresses content's own prerender triggers. User-defined prerender routes can still trigger the issue because the static import in database.server.js remains.

Approach 4 (user-side workaround): Disable all prerender in bun builds

In nuxt.config.ts:

const isBunPreset = (process.env.NITRO_PRESET || '').toLowerCase() === 'bun'
  || process.argv.some((a, i) =>
    (a === '--preset' && process.argv[i + 1] === 'bun') || a === '--preset=bun'
  )

export default defineNuxtConfig({
  content: {
    experimental: { sqliteConnector: 'bun' },
  },
  nitro: {
    prerender: {
      routes: isBunPreset ? [] : ['/spa-loading'],
    },
  },
  hooks: {
    ...(isBunPreset ? {
      'nitro:config'(nitroConfig) {
        for (const key of Object.keys(nitroConfig.routeRules || {})) {
          if (key.startsWith('/__nuxt_content/') && key.endsWith('/sql_dump.txt')) {
            nitroConfig.routeRules![key].prerender = false
          }
        }
      },
    } : {}),
  },
})

Pros: No changes to @nuxt/content source code; can be applied immediately.
Cons: Loses prerender capability; all pages must be generated at runtime. This is a workaround, not a real fix.

Related issues

  • oven-sh/bun#4290better-sqlite3 is not yet supported in Bun
  • nuxt/content#3741 — feat: add explicit bun sqlite connector for Bun runtime deployments (merged, but does not cover the prerender scenario)
  • nuxt/content#3758 — Support force node:sqlite instead of bun:sqlite (related but different scenario)

Reproduction steps

  1. Create a Nuxt 4 project with @nuxt/content >= 3.15.0
  2. Set content.experimental.sqliteConnector: 'bun' in nuxt.config.ts
  3. Add at least one content file (triggers collection generation → sql_dump.txt prerender route)
  4. Run nuxt build --preset bun in a Node.js environment
  5. Build fails at the Initializing prerenderer stage with: Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. Received protocol 'bun:'

Additional context

No response

Logs

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions