<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/mlly-vs-import-meta-resolve-vs-resolve-module-2026 -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/mlly-vs-import-meta-resolve-vs-resolve-module-2026/raw.md -->
<!-- Source path: content/guides/mlly-vs-import-meta-resolve-vs-resolve-module-2026.mdx -->

---
og_image: "/images/guides/mlly-vs-import-meta-resolve-vs-resolve-module-2026.webp"
title: "mlly vs import-meta-resolve vs resolve 2026"
description: "Compare mlly, import-meta-resolve, and resolve for resolving module paths in Node.js. ESM resolution, CJS require.resolve, import.meta.resolve, and which."
date: "2026-03-09"
author: "PkgPulse Team"
tags: ["nodejs", "typescript", "developer-tools", "automation"]
---

## TL;DR

**mlly** is the UnJS module resolution utility — resolves ESM and CJS imports, finds the nearest package.json, detects module types, and provides `createResolve` for custom resolvers. **import.meta.resolve** is the built-in ESM resolution API — resolves module specifiers to file URLs, stable in Node.js 20+. **resolve** is the classic `require.resolve` algorithm as a library — synchronous path resolution, works from any directory, used by bundlers and tools. In 2026: import.meta.resolve for built-in ESM resolution, mlly for advanced resolution utilities, resolve for CJS resolution in tools.

## Key Takeaways

- **mlly**: ~10M weekly downloads — UnJS, ESM utilities, resolves both ESM and CJS
- **import.meta.resolve**: built-in (Node.js 20+) — stable ESM resolution, returns file URLs
- **resolve**: ~50M weekly downloads — implements require.resolve algorithm, works from any basedir
- Module resolution converts specifiers ("vue") to file paths ("/node_modules/vue/dist/vue.mjs")
- ESM resolution uses `import.meta.resolve`, CJS uses `require.resolve`
- mlly bridges the gap with utilities that work across both module systems

---

## The Problem

```typescript
// CJS — require.resolve works great:
const vuePath = require.resolve("vue")
// → "/project/node_modules/vue/dist/vue.runtime.common.js"

// ESM — no require.resolve available:
// require.resolve("vue")  // ❌ ReferenceError: require is not defined

// import.meta.resolve is the ESM equivalent:
const vuePath = import.meta.resolve("vue")
// → "file:///project/node_modules/vue/dist/vue.runtime.esm-bundler.js"

// But what if you need to resolve from a different directory?
// Or detect if a module is ESM or CJS?
// Or resolve with custom conditions?
// → That's where mlly and resolve help
```

---

## mlly

[mlly](https://github.com/unjs/mlly) — ESM module utilities:

### Resolve modules

```typescript
import { resolve, resolvePath, createResolve } from "mlly"

// Resolve a module specifier:
const resolved = await resolve("vue", { url: import.meta.url })
// → "file:///project/node_modules/vue/dist/vue.runtime.esm-bundler.js"

// Resolve to a file path (not URL):
const filePath = await resolvePath("vue", { url: import.meta.url })
// → "/project/node_modules/vue/dist/vue.runtime.esm-bundler.js"

// Resolve with custom conditions:
const edgePath = await resolve("vue", {
  url: import.meta.url,
  conditions: ["edge", "worker", "import"],
})

// Create a resolver with fixed base URL:
const resolveFromProject = createResolve({ url: "/path/to/project/" })
const dep = await resolveFromProject("lodash")
```

### Detect module type

```typescript
import { isValidNodeImport, detectSyntax, getProtocol } from "mlly"

// Check if a file uses ESM or CJS:
const syntax = detectSyntax(`
  import { ref } from "vue"
  export default { setup() { return { count: ref(0) } } }
`)
// → { hasESM: true, hasCJS: false, isMixed: false }

const cjsSyntax = detectSyntax(`
  const vue = require("vue")
  module.exports = { count: 0 }
`)
// → { hasESM: false, hasCJS: true, isMixed: false }

// Check if a specifier is a valid Node.js import:
await isValidNodeImport("/path/to/module.mjs")  // true
await isValidNodeImport("/path/to/module.cjs")  // true
```

### Resolve path info

```typescript
import { resolvePathSync, fileURLToPath, pathToFileURL } from "mlly"

// Convert between file URLs and paths:
const url = pathToFileURL("/Users/royce/project/src/index.ts")
// → "file:///Users/royce/project/src/index.ts"

const path = fileURLToPath("file:///Users/royce/project/src/index.ts")
// → "/Users/royce/project/src/index.ts"
```

### Find nearest package.json

```typescript
import { findPackageJSON, readPackageJSON } from "mlly"

// Find the nearest package.json:
const pkgPath = await findPackageJSON(import.meta.url)
// → "/Users/royce/project/package.json"

// Find a dependency's package.json:
const vuePkgPath = await findPackageJSON("vue", { url: import.meta.url })
// → "/Users/royce/project/node_modules/vue/package.json"
```

### ESM interop utilities

```typescript
import { interopDefault, resolveImports } from "mlly"

// Handle default export interop (CJS → ESM):
const mod = await import("cjs-package")
const defaultExport = interopDefault(mod)
// Handles: { default: { default: fn } } → fn
// Handles: { default: fn } → fn

// Resolve all import specifiers in source code:
const code = `
  import { ref } from "vue"
  import { useRouter } from "vue-router"
`
const resolved = await resolveImports(code, { url: import.meta.url })
// Replaces "vue" and "vue-router" with resolved file URLs
```

---

## import.meta.resolve (Built-in)

[import.meta.resolve](https://nodejs.org/api/esm.html#importmetaresolvespecifier) — built-in ESM resolution:

### Basic usage

```typescript
// Resolve a bare specifier:
const vuePath = import.meta.resolve("vue")
// → "file:///project/node_modules/vue/dist/vue.runtime.esm-bundler.js"

// Resolve a relative path:
const utilsPath = import.meta.resolve("./utils.js")
// → "file:///project/src/utils.js"

// Resolve a subpath export:
const vueReactivity = import.meta.resolve("vue/reactivity")
// → "file:///project/node_modules/@vue/reactivity/dist/reactivity.esm-bundler.js"
```

### Checking if a package exists

```typescript
// Check if a package is installed:
function isPackageInstalled(name: string): boolean {
  try {
    import.meta.resolve(name)
    return true
  } catch {
    return false
  }
}

isPackageInstalled("vue")       // true (if installed)
isPackageInstalled("nonexist")  // false
```

### Limitations

```
import.meta.resolve:
  ✅ Built-in (Node.js 20+ stable)
  ✅ Zero dependencies
  ✅ Follows Node.js ESM resolution algorithm
  ✅ Respects package.json "exports" field

  ❌ Only resolves from current file's location
  ❌ Cannot resolve from arbitrary directories
  ❌ Returns file:// URLs (not paths)
  ❌ No CJS support (ESM only)
  ❌ No custom conditions parameter
  ❌ Synchronous (can't customize async hooks)
```

---

## resolve

[resolve](https://github.com/browserify/resolve) — require.resolve as a library:

### Basic usage

```typescript
import resolve from "resolve"

// Synchronous resolution:
const vuePath = resolve.sync("vue", {
  basedir: "/path/to/project",
})
// → "/path/to/project/node_modules/vue/dist/vue.runtime.common.js"

// Async resolution:
resolve("vue", { basedir: "/path/to/project" }, (err, resolved) => {
  console.log(resolved)
})

// Promise wrapper:
import { promisify } from "node:util"
const resolveAsync = promisify(resolve)
const path = await resolveAsync("vue", { basedir: process.cwd() })
```

### Custom resolution

```typescript
import resolve from "resolve"

// Resolve from a specific directory:
const path = resolve.sync("./utils", {
  basedir: "/path/to/project/src",
  extensions: [".ts", ".tsx", ".js", ".jsx"],
})

// Custom module directories:
const path2 = resolve.sync("my-package", {
  basedir: process.cwd(),
  moduleDirectory: ["node_modules", "custom_modules"],
})

// Package filter:
const path3 = resolve.sync("some-package", {
  basedir: process.cwd(),
  packageFilter: (pkg) => {
    // Use "module" field instead of "main":
    if (pkg.module) pkg.main = pkg.module
    return pkg
  },
})
```

### Path filter and resolution hooks

```typescript
import resolve from "resolve"

// Path filter — transform resolved paths:
const path = resolve.sync("my-lib", {
  basedir: process.cwd(),
  pathFilter: (pkg, path, relativePath) => {
    // Redirect imports:
    if (relativePath === "./lib/index.js") {
      return "./src/index.ts"
    }
    return relativePath
  },
})

// Check if a path is a core module:
resolve.isCore("fs")     // true
resolve.isCore("vue")    // false
resolve.isCore("path")   // true
```

### How bundlers use resolve

```typescript
// Webpack, Browserify, and other bundlers use resolve internally:

// Simplified bundler resolution:
import resolve from "resolve"

function resolveModule(specifier: string, fromFile: string) {
  return resolve.sync(specifier, {
    basedir: dirname(fromFile),
    extensions: [".js", ".ts", ".tsx", ".jsx", ".json"],
    packageFilter: (pkg) => {
      // Prefer "module" or "browser" fields:
      pkg.main = pkg.module || pkg.browser || pkg.main
      return pkg
    },
  })
}

// This is why bundlers can resolve TypeScript files,
// use package.json "module" field, etc.
```

---

## Feature Comparison

| Feature | mlly | import.meta.resolve | resolve |
|---------|------|-------------------|---------|
| ESM resolution | ✅ | ✅ | ❌ (CJS) |
| CJS resolution | ✅ | ❌ | ✅ |
| Custom basedir | ✅ | ❌ | ✅ |
| Custom conditions | ✅ | ❌ | ❌ |
| Package.json exports | ✅ | ✅ | ⚠️ (limited) |
| Syntax detection | ✅ | ❌ | ❌ |
| URL ↔ path conversion | ✅ | ❌ | ❌ |
| Sync API | ✅ | ✅ | ✅ |
| Built-in | ❌ | ✅ (Node.js 20+) | ❌ |
| Used by | Nuxt, UnJS | Node.js core | Webpack, Browserify |
| Weekly downloads | ~10M | Built-in | ~50M |

---

## When to Use Each

**Use mlly if:**
- Need ESM resolution utilities beyond import.meta.resolve
- Want to detect ESM vs CJS syntax in files
- Building tools that need to resolve from arbitrary directories
- In the UnJS ecosystem (Nuxt, Nitro, unbuild)

**Use import.meta.resolve if:**
- Resolving modules from the current file's location
- Want zero dependencies (built-in Node.js 20+)
- Checking if a package is installed
- Simple specifier-to-URL resolution

**Use resolve if:**
- Building a bundler or CJS-based tool
- Need to resolve from arbitrary base directories
- Want custom package.json field resolution (module, browser)
- Need the require.resolve algorithm as a library

---

## Community Adoption in 2026

**resolve** leads at approximately 50 million weekly downloads, reflecting its foundational role in the Node.js CommonJS toolchain. Webpack, Browserify, Jest, ESLint, and dozens of other major tools use `resolve` internally to implement module lookup. Despite the ecosystem's shift toward ESM, resolve remains essential for CJS-based tools that must resolve modules from arbitrary directories — a capability that `import.meta.resolve` does not provide.

**mlly** reaches approximately 10 million weekly downloads, driven by its role as the UnJS module resolution utility. Every application using Nuxt, Vite's Node.js plugin APIs, or unbuild depends on mlly for ESM-aware resolution. Its utility functions — `findStaticImports`, `parseStaticImport`, `resolvePath` — are the building blocks for tools that need to analyze or transform ES module import graphs.

**import.meta.resolve** is a Node.js built-in (stable since Node.js 20) and not tracked as an npm download. It is the zero-dependency option for simple resolution needs in ESM contexts. Its primary limitation — resolving only from the current file's location, not from an arbitrary basedir — is a fundamental design constraint of the ESM resolution algorithm, not a bug to be fixed in a future version.

## Migration Guide

### From resolve to mlly for ESM projects

The `resolve` package implements the CJS `require.resolve` algorithm. For ESM projects on Node.js 18+, mlly provides the equivalent ESM resolution utilities:

```typescript
// resolve (old — CJS algorithm)
import resolve from "resolve"
const vuePath = resolve.sync("vue", { basedir: process.cwd() })
// → "/project/node_modules/vue/dist/vue.runtime.common.js" (CJS entry)

// mlly (new — ESM algorithm, respects exports map)
import { resolvePath } from "mlly"
const vuePath = await resolvePath("vue", { url: import.meta.url })
// → "file:///project/node_modules/vue/dist/vue.runtime.esm-bundler.js" (ESM entry)
```

The key difference is that mlly respects the `exports` field in package.json (the modern ESM package entry point map), while `resolve`'s default behavior follows the older `main` field.


## Module Resolution in Monorepos and Build Tool Contexts

Module resolution becomes significantly more complex in monorepo environments where packages reference each other and tools must resolve imports across package boundaries.

**mlly in monorepos** understands pnpm's symlink structure. When package A imports from package B (a peer in the monorepo), pnpm creates a symlink in `node_modules/package-b` pointing to the actual source. mlly's `resolvePath` follows these symlinks correctly and resolves to the actual file path, not the symlink target. This matters for tools that need to watch files for changes — a watcher watching the symlink target rather than the symlink itself may not receive inotify/FSEvents notifications correctly. mlly's `isNodeBuiltin`, `normalizeid`, and `sanitizeFilePath` utilities are particularly useful in build tools that process import specifiers extracted from ASTs.

**import.meta.resolve()** has distinct behavior for bare specifiers (package names) versus relative paths. For bare specifiers, it resolves using Node.js's full resolution algorithm including `exports` field conditions. The `conditions` parameter lets you specify which export conditions to apply: `import.meta.resolve('pkg', undefined, ['node', 'import'])` uses the `import` condition, while `['node', 'require']` uses the CJS condition. This precise control makes `import.meta.resolve()` invaluable for tools that need to find exactly which file Node.js would load for a given import.

**The `resolve` npm package** (not `mlly`) is the long-established synchronous alternative used by Webpack, Babel, and many older tooling projects. It implements the CommonJS resolution algorithm and does not understand ESM `exports` fields. Projects migrating from CJS tooling to ESM tooling often encounter `resolve` as a transitive dependency — if `resolve` is the underlying mechanism and your package uses `exports` field-only entry points, the resolution will fail. Switching to `mlly` or `import.meta.resolve()` fixes these cases.

**Caching considerations** apply to all three approaches. Module resolution performs filesystem operations (stat calls, readdir), and in hot paths (like Vite's transform phase or a test runner's module loader), these operations should be cached. mlly encourages caching through its functional API — callers can implement their own caches. jiti (which uses mlly internally) maintains a module cache by file path. For custom tools that call resolution many times per second, measure and profile resolution calls to identify caching opportunities.

## TypeScript and ESM Interoperability Considerations

The shift from CommonJS to ESM has made module resolution significantly more complex, and each of these tools occupies a distinct niche in that complexity. TypeScript's module resolution modes (`bundler`, `node16`, `nodenext`) now map to different resolution algorithms, and understanding which resolution algorithm a tool uses is essential for debugging import failures in complex project setups.

mlly is aware of TypeScript's dual-mode publication pattern (packages that ship both `.cjs` and `.mjs` files with separate `exports` map conditions). When you call `mlly.resolvePath('some-package', { url })`, it follows the `exports` field conditions for the current module type, returning the `.mjs` entry for ESM consumers and the `.cjs` entry for CJS consumers. This is why build tools like unbuild and Vite's Node.js API plugins use mlly — they need resolution that accurately reflects what the runtime would do.

The `resolve` package (the CJS-only tool) handles the oldest widespread use case: given a package name and a directory, find the package's main entry point following the `require.resolve` algorithm. This includes reading `package.json`'s `main` field, checking the `node_modules/` directory tree, and respecting `.pnp.cjs` (Yarn PnP) if present. Tools like Jest (in its legacy CJS mode), ESLint, and webpack continue to depend on `resolve` because they implement their own module loading on top of the resolution algorithm. For these tools, `resolve`'s stability and its deep support for edge cases in the Node.js CJS resolution algorithm outweigh the benefits of switching to an ESM-aware tool.

Understanding the practical failure modes of each tool prevents hours of debugging. When mlly fails to resolve a package, the most common cause is a missing `exports` field condition — the package ships an `exports` map but does not include a condition that matches the current environment (e.g., the package has `browser` and `import` conditions but the tool requests `node` and `require`). When `import.meta.resolve` fails, it is typically because the package is not installed (bare specifier resolution requires the package to be present in `node_modules`) or the file URL provided as the second argument is not a valid file URL. When `resolve` fails, it is usually because the package uses only `exports` field entry points (no `main` field) and `resolve`'s default mode does not read the `exports` field. In each case, the error message points to the resolution step that failed, making these debugging sessions tractable once you understand the resolution algorithm differences.

## Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on mlly v1.x, Node.js 22 import.meta.resolve, and resolve v1.x.

*[Compare module resolution and developer tooling on PkgPulse →](https://www.pkgpulse.com)*

*See also: [cac vs meow vs arg 2026](/guides/cac-vs-meow-vs-arg-lightweight-cli-argument-parsers-2026) and [cosmiconfig vs lilconfig vs conf](/guides/cosmiconfig-vs-lilconfig-vs-conf-configuration-loading-2026), [archiver vs adm-zip vs JSZip (2026)](/guides/archiver-vs-adm-zip-vs-jszip-zip-archive-creation-2026).*
