Running Node.js Projects on Deno 2: npm Compatibility, What Works, and What Breaks
Key takeaways
Deno 2 can run most npm packages and many Node.js projects unchanged, but 'most' hides the details that decide a migration: how dependencies are resolved, how CommonJS is detected, why install scripts do not run by default, and what the permission flags mean when your dependencies need the filesystem.
What Changed in Deno 2
Deno 2 (released October 2024) made Node.js and npm compatibility a first-class feature:
| Feature | Deno 1.x | Deno 2 |
|---|---|---|
| npm packages | npm: specifiers (from 1.28) | Full support, plus package.json |
node_modules/ | Opt-in | Created automatically when a package.json exists |
package.json | Partial (from 1.31) | Supported for dependencies and scripts |
require() | Via createRequire only | In CommonJS files |
| Node built-ins | Partial | Broad coverage (node:fs, node:http, …) |
deno install | Installed a script as a command | Installs project dependencies |
For the Deno runtime itself (permissions, TypeScript without configuration, built-in tooling), see the Deno 2 overview. This article is about one question: what happens when you point Deno 2 at an existing Node.js project or an npm dependency, and which parts of that need attention.
The short answer from trying this on typical backend projects: plain JavaScript and TypeScript code with pure-JS dependencies (Express, Zod, most utility libraries) tends to work immediately. Problems cluster around a few specific areas, and each of them is predictable once you know how Deno resolves and runs npm code.
Two ways to use npm packages
npm: specifiers
// Import directly by specifier: no package.json, no install step
import express from 'npm:express@4'
import { z } from 'npm:zod@3'
const app = express()
app.get('/', (req, res) => {
res.json({ message: 'Express on Deno!' })
})
app.listen(3000)
deno run --allow-net --allow-env server.ts
With an npm: specifier, Deno downloads the package into its global cache the first time it runs and resolves it from there. No node_modules directory is created by default, and the version is recorded in deno.lock. This is convenient for scripts and new projects. Move the specifiers into the imports map of deno.json ("express": "npm:express@4") once a project has more than a couple of files, so versions live in one place instead of being repeated in every import.
The --allow-env flag in the command above is not a typo, even though the example code never touches environment variables. Express reads NODE_ENV, and Deno applies permissions to all code in the process, including dependencies. Other packages read config files or spawn processes at runtime and need the corresponding flags. Without them, Deno prompts interactively in a terminal, or fails with a NotCapable error in CI. That is the permission model working as designed, but it surprises people on the first run.
package.json (existing Node.js projects)
{
"type": "module",
"dependencies": {
"express": "^4.18.0",
"zod": "^3.22.0"
},
"scripts": {
"start": "node src/server.js"
}
}
deno install # installs into node_modules/, like npm install
deno run -A src/server.js # run the existing entry point
deno task start # runs package.json scripts too
When a package.json is present, Deno 2 reads the dependencies, installs them into a local node_modules directory, and resolves bare imports (import express from 'express') the way Node does. This is the mode to use for migrating an existing project, because it keeps tooling that expects node_modules (bundlers, type checkers, editors) working. deno task runs package.json scripts as well as deno.json tasks, but a script that literally calls node still runs Node. Replace it with deno run if you want the task to run on Deno.
CommonJS vs ES modules
Deno is ESM-first, and most “it doesn’t run” reports in practice come down to module format. The rules follow Node’s:
.mjsfiles are always ES modules,.cjsfiles are always CommonJS.- A
.jsfile is CommonJS if the nearestpackage.jsonhas"type": "commonjs"or notypefield, and an ES module if it has"type": "module". - npm packages are loaded according to their own
package.json, so CommonJS dependencies work from ESM code with a normalimport.
// src/legacy.cjs — CommonJS works in .cjs files
const path = require('node:path')
module.exports = { root: path.resolve(__dirname, '..') }
// src/server.mjs — ESM can import the CommonJS module
import legacy from './legacy.cjs'
console.log(legacy.root)
Two patterns break regularly when moving CommonJS code to ESM, in Deno as in Node. __dirname and __filename do not exist in ES modules: use import.meta.dirname and import.meta.filename. And require() of a local file from an ES module does not work without createRequire. If an existing project mixes formats in .js files without a type field, it only ever worked because of how Node guessed. Setting "type" explicitly and renaming outliers to .cjs usually resolves it.
Lifecycle scripts and native addons
Deno does not run npm lifecycle scripts (preinstall, install, postinstall) by default. This is deliberate: install scripts are a well-known supply chain attack vector, because they run arbitrary code with the user’s full permissions on npm install, before any application code runs.
# Allow install scripts only for packages that need them
deno install --allow-scripts=npm:sharp,npm:esbuild
The consequence is that packages which download a platform binary or compile C++ code during installation, such as sharp, esbuild, bcrypt, sqlite3, or Prisma’s engines, fail at runtime until you allow their scripts. The error rarely says so directly. It is usually “cannot find module” for a missing .node file, or a missing binary. When a dependency fails in Deno but works with npm install, check its package.json for an install script first.
Native addons (.node files built with node-gyp or N-API) additionally need --allow-ffi to load, since they execute native code outside Deno’s sandbox. Many popular native packages work this way, but this is the least mature area of the compatibility layer. Pure-JS or WebAssembly alternatives (bcryptjs instead of bcrypt, for example) avoid the issue.
This is the area where I would budget the most time in a migration. The application code usually runs on the first try. Then the image-processing library, the database driver, or the password hashing package fails, and each one needs a separate decision: allow its scripts, grant FFI, or replace it. Listing all dependencies with install scripts or .node files before starting (npm query ':attr(scripts, [postinstall])' helps with the first) turns that surprise into a checklist.
Permissions with npm dependencies
--allow-net # all network access
--allow-net=api.com # only api.com
--allow-read # all filesystem reads
--allow-read=./data # only ./data directory
--allow-write # all filesystem writes
--allow-env # all env vars
--allow-env=API_KEY # only API_KEY
--allow-run # spawn subprocesses
--allow-ffi # load native libraries / addons
--allow-scripts # (install) run npm lifecycle scripts
--allow-all / -A # everything (like Node.js — use carefully)
The permission model is Deno’s main security advantage over Node, and npm compatibility interacts with it in ways worth understanding before relying on it.
Permissions are per process, not per package. If your server needs --allow-read=./uploads and --allow-net, every dependency gets exactly the same access. A malicious or compromised package cannot read ~/.ssh if you did not grant broad read access, which is real protection. But it can use whatever network access your application needs. Scope flags as narrowly as the application allows (specific hosts, specific directories, specific env var names), rather than starting with -A and never removing it.
Some flags effectively grant everything. --allow-run lets code start any program, including another Deno or a shell without restrictions. --allow-ffi loads native code that ignores the sandbox. --allow-write to a directory that contains code or configuration that later runs with more permissions has the same effect. If an application needs these, the sandbox provides little protection beyond documentation of intent.
Many npm packages read environment variables or files you would not expect (config lookups, NODE_ENV, locale files). Running once without -A and reading the permission prompts is the quickest way to learn what each dependency actually touches, and I find it one of the more useful side effects of trying a project on Deno, even if you stay on Node.
JSR alongside npm
// deno.json
{
"imports": {
"@std/path": "jsr:@std/path@^1.0.0",
"@std/assert": "jsr:@std/assert@^1.0.0",
"hono": "jsr:@hono/hono@^4.0.0",
"zod": "npm:zod@^3.22.0"
}
}
JSR packages publish TypeScript source directly, so there is no separate build step or .d.ts files to keep in sync, and the registry generates documentation from the types. For a mixed project, a reasonable rule is: use JSR for packages that publish there (Deno’s standard library @std/*, Hono, Oak), and npm for everything else. Both can be mixed freely in one import map. JSR packages can also be installed into Node and Bun projects through an npm-compatible layer (npx jsr add @std/path), so choosing a JSR package does not lock a project into Deno.
Migrating a Node.js project: a practical order
// Node built-ins work with or without the node: prefix; the prefix is clearer
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { createServer } from 'node:http'
// Node-only globals that ESM does not provide
const here = import.meta.dirname // instead of __dirname
const file = join(here, 'config.json')
A migration goes most smoothly in small steps, each of which can be undone:
- Run it unchanged.
deno installthendeno run -A src/server.js. With-A, any failure is a compatibility problem, not a permission problem. - Fix module format issues (section 2) and install-script/native-addon dependencies (section 3).
- Run the test suite on Deno. If it uses Jest or Vitest, check whether they run under Deno for your version. Deno’s built-in
deno testsupports thenode:testAPI as well, which can be a simpler path for small suites. - Remove
-Aand add the narrowest permission flags that make the tests pass. - Only then adopt Deno-specific APIs (
Deno.serve,@std/*), if at all. Code that stays onnode:APIs keeps running on Node, which keeps the migration reversible.
Step 5 is optional, and skipping it is a legitimate choice. The main benefits of running on Deno (the permission sandbox, built-in TypeScript, formatter, linter, and test runner) do not require rewriting code to Deno APIs.
Deno vs Node.js vs Bun for existing projects
| Deno 2 | Node.js 22 | Bun 1.x | |
|---|---|---|---|
| TypeScript | Native | Type stripping (22.6+, flag) or tsx | Native |
| npm compat | High; install scripts opt-in | Native | High |
| Security | Permissions model | Full access (experimental permission model) | Full access |
| Built-in tools | fmt, lint, test, bench, compile | test runner | Bundler, test, package manager |
| Main migration risk | Install scripts, native addons, permissions | None | Node API edge cases |
See Bun vs Node.js performance for the Bun side of this comparison.