EmpireUI
Get Pro
← Blog9 min read#deno#deno 2#npm

Deno 2.0 for Web Developers: npm compat, JSR and Fresh 2

Deno 2.0 ships full npm compatibility, JSR as a modern registry, and Fresh 2's islands architecture. Here's what actually changes for your workflow.

abstract dark terminal code glowing on developer screen

What Deno 2.0 Actually Is (and Isn't)

Deno 2.0 isn't a rewrite. It's the same V8-based, TypeScript-native runtime that Ryan Dahl shipped in 2018, but finally grown up. The headline change is npm compatibility — real, functional npm compatibility, not the shimmy approximation you got in Deno 1.x with --unstable flags and crossed fingers.

What changed in practice: node_modules is now opt-in rather than banned. You can run deno install and get a local node_modules folder exactly like you would with Node. More importantly, npm: specifiers in imports just work. import express from "npm:express@4" resolves, downloads, and runs without ceremony. That's a genuinely different story from 2022.

Worth noting: Deno still won't execute an npm install automatically. You have to be intentional. That's a feature, not a bug — you know exactly what's happening during startup, which matters a lot in serverless environments where cold start time costs you real money.

Look, the honest reason most teams avoided Deno before wasn't ideology. It was the npm gap. If your project touched anything that pulled from the npm ecosystem — and what project doesn't? — you were stuck. That gap is closed now.

npm Compatibility: The Details That Matter

The npm: specifier covers the vast majority of packages you'd realistically reach for. Express, Zod, date-fns, Prisma, React — they all work. Deno downloads them from the npm registry, caches them in DENO_DIR (not your project root unless you set nodeModulesDir: true in deno.json), and resolves CommonJS automatically via its built-in CJS-to-ESM wrapper.

// deno.json
{
  "imports": {
    "zod": "npm:zod@3",
    "hono": "npm:hono@4"
  },
  "nodeModulesDir": false
}

The CJS situation is worth dwelling on. Deno wraps CommonJS modules at import time using an internal transform. It's not perfect — some packages with dynamic require() calls or native .node bindings still fail — but the 95% case works without you doing anything. require() is also now available globally in Deno 2.0 when you're in a CJS context, which sounds minor but unblocks a long tail of older packages.

In practice, the packages that still break are ones with native bindings (think sharp, better-sqlite3, bcrypt). You'll hit a wall and need to swap them for alternatives. For sharp there's already an npm package that wraps Wasm, so image processing is covered. SQLite you'd use Deno's built-in @std/sqlite anyway. This isn't a dealbreaker — it's the same pain you'd have moving between any two environments.

Quick aside: deno task is the npm-scripts replacement and it's genuinely nicer. You define tasks in deno.json, they run cross-platform, and you can compose them without extra tooling. Not revolutionary, but you'll appreciate it the first time you stop writing shell script hacks.

JSR: Why It Exists and When You Should Use It

JSR is the JavaScript Registry — Deno Company's answer to npm's technical debt. The pitch is simple: packages are TypeScript-first, docs are auto-generated from JSDoc, and the module graph is fully static. No package.json, no node_modules concept on the publishing side, just URL-based imports that work across Deno, Node, Bun, and browsers.

// import from JSR — works in Deno natively
import { groupBy } from "jsr:@std/collections@1";

const grouped = groupBy(
  [{name: "Alice", role: "dev"}, {name: "Bob", role: "design"}],
  (p) => p.role
);

The @std/* packages on JSR are the Deno Standard Library, finally versioned properly. In Deno 1.x, std was versioned by the Deno release, which meant updating Deno could silently break your std imports. That was genuinely painful. JSR decouples them — @std/path@1.0.0 is @std/path@1.0.0 regardless of your Deno version.

Should you use JSR for publishing your own packages? Yes, if you're writing TypeScript and care about runtime portability. JSR auto-generates type declarations and compatibility shims for Node and Bun, so you publish once and consumers on any runtime get a good experience. The tooling for it (deno publish) is fast and the API key setup takes about 90 seconds.

Honestly, npm isn't going anywhere. JSR is additive, not a replacement. Think of it as npm but designed in 2024 instead of 2009. For greenfield packages you own, it's the better choice. For consuming the existing ecosystem, npm: specifiers are your path.

Fresh 2: Islands Architecture, Rebuilt

Fresh is Deno's full-stack web framework — file-based routing, zero-JS-by-default, Preact on the frontend. Fresh 2 landed alongside Deno 2.0 and it's a meaningful upgrade rather than just version bumping.

The biggest change is the islands system. In Fresh 1.x, islands were special files you dropped in an islands/ directory. In Fresh 2, any component can be an island using the "use client" directive — which if you've used React Server Components will feel immediately familiar. The mental model is the same: server renders HTML, islands opt into hydration. You're not shipping JS for components that don't need it.

// routes/index.tsx — server component, zero JS shipped to client
import Counter from "../islands/Counter.tsx";

export default function Home() {
  return (
    <main>
      <h1>Hello</h1>
      {/* Counter hydrates, the h1 above doesn't */}
      <Counter start={0} />
    </main>
  );
}
// islands/Counter.tsx
import { useSignal } from "@preact/signals";

export default function Counter({ start }: { start: number }) {
  const count = useSignal(start);
  return (
    <button onClick={() => count.value++}>
      Count: {count}
    </button>
  );
}

Fresh 2 also drops the bespoke build system in favor of esbuild under the hood, which gets you source maps, better error messages, and noticeably faster dev server startup. In testing on a mid-size project, cold start went from around 1.8 seconds to under 400ms. That's the kind of difference you feel every time you deno task dev.

Migrating an Existing Node Project

The migration path is less terrible than you'd expect. Start by creating a deno.json at the root and mapping your npm dependencies as npm: specifiers under imports. Then run deno check src/index.ts and fix whatever the type checker surfaces. There'll usually be a handful of Node-only globals you've been using casually — __dirname, process.env — that need minor adjustments.

// deno.json
{
  "imports": {
    "express": "npm:express@4",
    "dotenv": "npm:dotenv@16",
    "@types/express": "npm:@types/express@4"
  },
  "tasks": {
    "dev": "deno run --allow-net --allow-env --allow-read src/index.ts",
    "build": "deno compile --allow-net src/index.ts -o dist/server"
  },
  "permissions": {
    "net": true,
    "env": true,
    "read": ["./public"]
  }
}

The permissions model trips people up at first. Deno requires you to explicitly allow filesystem reads, network access, and env var access. That feels annoying in dev but it's actually useful — you'll catch libraries doing things you didn't know about. In production it's a real security win. You can lock down exactly what your server process is allowed to touch.

One more thing — deno compile deserves a mention here. It bundles your entire app into a single self-contained binary. No Node runtime needed on the target machine, no Docker image managing npm install. The output is around 80–100MB for a typical app (Deno runtime is included), but you can cross-compile for Linux, macOS, and Windows from any host. For internal tooling or simple APIs, this is genuinely great.

That said, don't try to migrate everything at once. Start with a standalone service — a webhook handler, a background job, a CLI tool — and get comfortable with the permissions model and the import syntax before touching critical paths. The biggest risk in migration isn't technical; it's the team needing to unlearn muscle memory around npm run and require().

Permissions, Security, and the Runtime Sandbox

Deno's security model is worth spending more than five minutes on. The default stance is deny-all: if you deno run a script without flags, it can't read files, make network requests, or touch env vars. You opt in per capability. This is completely different from Node, where require('fs') just works and you're trusting every dependency in your tree implicitly.

The 2.0 release tidied up the permission flags considerably. You can now specify them in deno.json under a "permissions" key, which means your CI/CD doesn't need different flags than local dev. You can also scope them precisely — "read": ["./src", "./public"] rather than "read": true. That granularity matters when you're auditing what a third-party npm package can actually touch.

# Run with explicit, scoped permissions
deno run \
  --allow-net=api.example.com:443 \
  --allow-env=DATABASE_URL,PORT \
  --allow-read=./data \
  src/server.ts

Why does this matter for UI work? If you're building a design tool or a frontend app — maybe something layered on top of Empire UI components — and you're using Deno to serve assets or run a local dev API, sandboxing means a compromised dependency can't exfiltrate your filesystem. It's the kind of protection you didn't know you wanted until you read about a supply chain attack.

Honestly, the permissions system is the most underrated part of Deno. Most tutorials skim it and slap --allow-all everywhere to avoid friction. Don't do that. Take 20 minutes to scope your permissions properly and you'll have a significantly more auditable deployment.

Should You Actually Switch?

Here's a direct answer: if you're starting a new project today and it doesn't depend heavily on native Node bindings, Deno 2.0 is a legitimate choice. The developer experience is genuinely better — built-in TypeScript, built-in formatter (deno fmt), built-in linter (deno lint), built-in test runner (deno test) — you're not assembling a toolchain, you're just writing code.

For existing projects, the ROI depends on what you're building. A REST API with standard packages? Totally migratable. A complex Next.js app with 200 npm dependencies and some native bindings? Probably not worth it right now. Fresh 2 is compelling if you want the islands pattern without React Server Components complexity, but it's a different framework and your team will feel that.

The tooling story is the strongest argument. deno fmt uses the same formatting as Prettier, runs in about 40ms on a medium-sized codebase (versus Prettier's 800–1200ms), and needs zero config. deno lint catches real issues out of the box, not just style nits. If your current setup has a gnarly ESLint config with 12 plugins, you'll immediately feel the difference.

For styling-heavy frontend work — you're probably reaching for a component library anyway. Empire UI covers glassmorphism components, neumorphism, neobrutalism, and more. Those components are framework-agnostic enough that you could slot them into a Fresh 2 project with Preact without much pain. The gradient generator and box shadow generator don't care what runtime serves your HTML.

The bottom line: Deno 2.0 is no longer the scrappy alternative that requires you to give up half your dependencies. It's a serious runtime with a coherent security model, a great DX, and a growing ecosystem via JSR. Whether you move today or wait another year depends on your risk tolerance — but you should at least spin up a side project with it, because the gap with Node has genuinely closed.

FAQ

Can I use React with Deno 2.0?

Yes. import React from "npm:react@18" works out of the box. You'd typically pair it with a bundler like esbuild (via deno task) or just use Fresh 2, which ships Preact but follows the same islands model.

Does Deno 2.0 support `package.json`?

It reads package.json for compatibility when you're in a Node-compat project, but deno.json is the idiomatic config file. You don't need package.json for greenfield Deno projects.

What's the difference between JSR and npm?

npm hosts CommonJS and ESM packages with a package.json manifest. JSR is TypeScript-first, statically analyzable, and cross-runtime by design. You can use both in the same Deno project simultaneously via npm: and jsr: specifiers.

Is Fresh 2 production-ready?

Yes. It's been running deno.land and several production apps since mid-2025. The islands model is stable, the esbuild pipeline is solid, and the team has committed to long-term API stability for 2.x.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Monorepo Design System: Shared Packages, Storybook, PublishingBun vs Node.js in 2026: Benchmarks, Compat and When to SwitchDesign System Versioning: Semantic Versions, Changelogs and Breaking Changespnpm vs npm vs Yarn in 2026: Package Manager Showdown