Skip to content

Fix: unbuild Not Working — Build Output Empty, Stub Mode Failing, or Rollup Errors

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix unbuild issues — build configuration, stub mode for development, ESM and CJS output, TypeScript declarations, external dependencies, and monorepo workspace builds.

The Problem

unbuild produces an empty dist/ directory:

npx unbuild
# Build succeeded but dist/ is empty or missing expected files

Or stub mode doesn’t work:

npx unbuild --stub
# Error: Cannot find module './dist/index.mjs'

Or the build fails with a rollup error:

[rollup] Error: Could not resolve './utils' from 'src/index.ts'

Why This Happens

unbuild is a unified build system from the UnJS ecosystem. It is the build tool behind Nuxt, Nitro, h3, and most other UnJS packages. Internally it combines Rollup (for JavaScript bundling), mkdist (for file-by-file TypeScript compilation), esbuild (for TypeScript-to-JS transformation), and rollup-plugin-dts (for type declaration bundling). The output is a clean dual-format package — ESM .mjs and CommonJS .cjs — with matching .d.mts and .d.cts declarations, plus proper package.json exports resolution. This combination is why unbuild produces smaller, more standards-compliant packages than tsc alone or tsup defaults — but it is also why misconfigured package.json fields silently break the build.

Three behaviors trip people up. Entry points are inferred from package.json — unbuild reads main, module, exports, and bin and works backward to figure out which source files to build. If these fields point to dist/index.mjs but no src/index.ts exists, unbuild guesses wrong or produces nothing. Adding an explicit entries array in build.config.ts overrides the inference and is the safer pattern for libraries with multiple subpaths. Stub mode (unbuild --stub) creates proxy files that use jiti to compile source on the fly — the dist/index.mjs file is a one-line proxy that re-imports src/index.ts. This lets consumers import from the package name and get live updates without rebuilding. The trade-off is that stub-mode output only works in Node.js (jiti is a Node-only runtime compiler) and must be replaced with a real build before publishing.

Rollup resolution is stricter than Node.js. Source files can use extensionless imports (./utils) and unbuild’s Rollup config resolves them via @rollup/plugin-node-resolve, but only if the file actually exists at the resolved path. Missing extensions on workspace packages, broken paths aliases in tsconfig.json, and package.json files with no exports field all cause “Could not resolve” errors at build time. Dependency inlining defaults differ from tsup. unbuild externalizes everything in dependencies and peerDependencies automatically, but it inlines anything else unless you list it in externals. This is the opposite of tsup’s default behavior, which catches developers migrating between the two.

Platform and Environment Differences

unbuild is Node-only as a build tool but produces output that runs on any JavaScript runtime your package targets. The key distinctions are around which bundler combination it activates per file type.

Rollup vs mkdist. Files listed directly in entries go through Rollup, which means they get bundled into a single output file per entry. Files inside an mkdist entry ({ input: 'src/runtime/', builder: 'mkdist' }) are transformed one-by-one without bundling, preserving the original file structure in the output. Nitro and Nuxt use mkdist for runtime modules because those files must be tree-shakable at the consumer’s build step. Pick mkdist when you want each .ts file to map to one .mjs file; pick the default Rollup builder when you want a single bundled entry per export.

ESM vs CJS output. By default unbuild emits ESM only. Set rollup.emitCJS: true to also generate .cjs files. The matching package.json exports field must list both conditions for Node to resolve correctly under require() and import. Forgetting to add require to exports makes the CJS output unreachable even though the file exists. TypeScript declaration generation uses rollup-plugin-dts for bundled entries and mkdist for unbundled ones. Both rely on your installed TypeScript version, so a tsconfig.json with strict settings can fail declaration generation while leaving the JS output intact — the build prints a warning but exits 0, which surfaces later as missing .d.ts files in the published package.

Package.json exports resolution. Node and bundlers use the exports field to find the right file per environment. unbuild does not write the exports field for you — you must keep it in sync with the entry list. The canonical pattern uses nested conditions: import.mjs plus .d.mts, require.cjs plus .d.cts. Skipping the types condition inside each branch breaks TypeScript resolution in TS 5.0+ projects with moduleResolution: "bundler" or "node16".

Monorepo build orchestration. In a Turborepo or pnpm workspace, packages built with unbuild become consumers of each other. The workspace package’s dist/ must exist before a sibling package’s build starts, otherwise resolution fails. The simplest fix is unbuild --stub in dev (no real build needed) and an ordered turbo build in CI that respects the dependency graph. List sibling workspace packages in externals so unbuild does not try to inline them, and rely on the consumer’s build to resolve the package by name. See Fix: pnpm Workspace Not Working for the resolution layer underneath, and Fix: Turborepo Not Working for the orchestration layer above.

Fix 1: Basic Configuration

npm install -D unbuild
// build.config.ts
import { defineBuildConfig } from 'unbuild';

export default defineBuildConfig({
  // Entry points — auto-detected from package.json if not specified
  entries: ['src/index'],

  // Generate TypeScript declarations
  declaration: true,

  // Output formats
  rollup: {
    emitCJS: true,  // Generate CommonJS output alongside ESM
    inlineDependencies: false,  // Don't bundle node_modules
  },

  // Clean dist/ before building
  clean: true,

  // Externals — don't bundle these
  externals: [
    'react',
    'react-dom',
    /^@types\//,
  ],
});
// package.json — unbuild reads these fields
{
  "name": "my-lib",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.mts",
        "default": "./dist/index.mjs"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "unbuild",
    "dev": "unbuild --stub",
    "prepublishOnly": "npm run build"
  }
}

Fix 2: Multiple Entry Points

// build.config.ts
import { defineBuildConfig } from 'unbuild';

export default defineBuildConfig({
  entries: [
    'src/index',           // Main entry
    'src/utils',           // ./dist/utils.mjs
    'src/cli',             // CLI entry
    {
      input: 'src/runtime/',   // Directory entry — builds all files
      outDir: 'dist/runtime',
    },
  ],
  declaration: true,
  rollup: { emitCJS: true },
  clean: true,
});
// package.json — exports for multiple entries
{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./utils": {
      "import": "./dist/utils.mjs",
      "require": "./dist/utils.cjs"
    }
  },
  "bin": {
    "my-cli": "./dist/cli.mjs"
  }
}

Fix 3: Stub Mode for Development

# Stub mode — creates proxy files for live development
npx unbuild --stub
# dist/ after --stub:
# dist/index.mjs → proxy that imports src/index.ts via jiti
# dist/index.cjs → proxy that requires src/index.ts via jiti
// The stub proxy looks roughly like:
// dist/index.mjs
import jiti from 'jiti';
const _jiti = jiti(import.meta.url);
export default _jiti('/absolute/path/to/src/index.ts');

// This means:
// 1. Importing from dist/ actually runs src/ through jiti
// 2. Changes to src/ are reflected immediately (no rebuild)
// 3. TypeScript is compiled on-the-fly
// For monorepo development — stub all packages
{
  "scripts": {
    "dev": "unbuild --stub",
    "build": "unbuild"
  }
}
# In monorepo root
npx turbo dev  # Runs unbuild --stub in all packages
# Now packages can import each other's latest source

Fix 4: Handle TypeScript Paths and Aliases

// build.config.ts
import { defineBuildConfig } from 'unbuild';

export default defineBuildConfig({
  entries: ['src/index'],
  declaration: true,
  rollup: {
    emitCJS: true,
  },

  // Resolve aliases — match tsconfig paths
  alias: {
    '@': './src',
    '~': './src',
  },

  // Hook into rollup config
  hooks: {
    'rollup:options': (ctx, options) => {
      // Customize rollup options
    },
  },
});
// tsconfig.json — paths must match alias
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

Fix 5: Monorepo Workspace Builds

// packages/core/build.config.ts
import { defineBuildConfig } from 'unbuild';

export default defineBuildConfig({
  entries: ['src/index'],
  declaration: true,
  rollup: { emitCJS: true },
  clean: true,

  // External workspace packages
  externals: [
    '@myorg/utils',       // Don't bundle workspace siblings
    '@myorg/shared',
  ],
});

// packages/react/build.config.ts
import { defineBuildConfig } from 'unbuild';

export default defineBuildConfig({
  entries: ['src/index'],
  declaration: true,
  rollup: { emitCJS: true },
  clean: true,
  externals: [
    'react',
    'react-dom',
    '@myorg/core',  // Peer dependency
  ],
});

Fix 6: Mixing Rollup and mkdist Builders

Real packages often need both bundled entries (for the public API) and per-file output (for runtime modules consumers can tree-shake). unbuild handles this by letting each entry pick its builder:

// build.config.ts — mixed builders
import { defineBuildConfig } from 'unbuild';

export default defineBuildConfig({
  entries: [
    // Rollup-bundled entry — single file output, full tree-shaking inside the bundle
    {
      builder: 'rollup',
      input: 'src/index',
    },

    // mkdist entry — per-file transformation, preserves directory structure
    {
      builder: 'mkdist',
      input: 'src/runtime/',
      outDir: 'dist/runtime',
      format: 'esm',
      // Each .ts file in src/runtime/ becomes a separate .mjs file in dist/runtime/
      // Consumers' bundlers can tree-shake at the file level
    },

    // mkdist for templates or untouched assets
    {
      builder: 'mkdist',
      input: 'src/templates/',
      outDir: 'dist/templates',
      pattern: ['**/*.tpl', '**/*.hbs'],
    },
  ],

  declaration: 'compatible',  // 'compatible' for both .d.ts and .d.mts/.d.cts
  rollup: {
    emitCJS: true,
  },
  clean: true,
});

Use the Rollup builder when the entry is consumed via import { thing } from 'pkg'. Use mkdist when the entry is a directory of files that the consumer references by relative path (e.g., import 'pkg/runtime/auto.mjs'). Mixing the two in a single build is the pattern Nitro and Nuxt use to ship core APIs as bundles while exposing runtime files individually.

Fix 7: Advanced Rollup Configuration

// build.config.ts — fine-tune rollup
import { defineBuildConfig } from 'unbuild';

export default defineBuildConfig({
  entries: ['src/index'],
  declaration: true,

  rollup: {
    emitCJS: true,

    // Inline specific dependencies (bundle them)
    inlineDependencies: false,

    // Rollup output options
    output: {
      exports: 'named',
      preserveModules: false,  // Bundle into single file
    },

    // Replace values
    replace: {
      'process.env.NODE_ENV': JSON.stringify('production'),
      __VERSION__: JSON.stringify(require('./package.json').version),
    },

    // Resolve options
    resolve: {
      preferBuiltins: true,
    },

    // CommonJS interop
    commonjs: {
      requireReturnsDefault: 'auto',
    },

    // JSON support
    json: {
      preferConst: true,
    },

    // esbuild options (used for TS compilation)
    esbuild: {
      target: 'node18',
      minify: false,
    },
  },
});

Still Not Working?

dist/ is empty after build — unbuild determines entry points from package.json fields (main, module, exports). If these point to files that don’t have corresponding source files, nothing is built. Add explicit entries in build.config.ts to override auto-detection.

Stub mode throws “Cannot find module”jiti must be installed (it’s a dependency of unbuild). Also, the consuming code must import from the package name (not a relative path to dist). In a monorepo, the workspace package resolution must point to the stubbed dist/.

Rollup can’t resolve imports — relative imports like ./utils need to match actual files. If the source file is utils.ts, unbuild’s rollup config should resolve it. Add file extensions in imports (./utils.js) or configure the alias option in build.config.ts.

Declaration files not generated — set declaration: true in build.config.ts. unbuild uses mkdist or rollup-plugin-dts for declarations. If TypeScript errors exist in your source, declarations may fail silently. Run tsc --noEmit first to check for errors.

Build succeeds but consumers fail with “Cannot find module” — your package.json exports field does not match the files unbuild emitted. Check that each export points at a real file in dist/ and that both import and require conditions are present if you set rollup.emitCJS: true. The fastest verification is npm pack followed by inspecting the tarball’s dist/ contents against the exports map.

Stub mode works locally but breaks in CI — CI ran npm ci with NODE_ENV=production, which skipped devDependencies. jiti is a devDependency of unbuild, so the stub proxies fail at import time. Either install with dev dependencies in CI or run a real unbuild build before any test step that imports the stubbed package.

Output bundles the wrong dependency version — a transitive dependency was inlined because it was not listed in externals and was not in your dependencies. Add explicit externals for sensitive packages (React, Vue, Node built-ins), or list them in peerDependencies so unbuild externalizes them automatically.

Tree-shaking removes named exports your consumers need — Rollup pruned them because nothing inside the bundle uses them. Add the exports to entries as separate sub-entries, or mark them via output.preserveModules: true so the file structure stays unflattened.

For related build tool issues, see Fix: tsup Not Working and Fix: esbuild Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles