Building Cross-Platform Apps with Deno 2.0: CLI Tools, HTTP Servers, and npm Integration

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Quick Start: Up and Running in 5 Minutes

If you’ve been hearing about Deno but kept putting it off, Deno 2.0 is the version that finally makes it click. I picked it up while looking for a way to run TypeScript scripts without a build step. What surprised me: the whole install-and-run cycle took under three minutes, and I never touched a tsconfig.

Install Deno with a single command — no npm, no node_modules:

# macOS / Linux
curl -fsSL https://deno.land/install.sh | sh

# Windows (PowerShell)
irm https://deno.land/install.ps1 | iex

# Or via package managers
brew install deno          # macOS Homebrew
winget install DenoLand.Deno  # Windows

Verify the install:

deno --version
# deno 2.0.x (release, ...)

Now run your first TypeScript file — no compiler, no config file:

// hello.ts
const name: string = "Deno 2.0";
console.log(`Hello from ${name}!`);
deno run hello.ts
# Hello from Deno 2.0!

That’s it. TypeScript runs directly. Compare that to a typical Node + TypeScript setup: npm init, install ts-node or tsx, write a tsconfig.json, then finally run your file. Deno skips all of it.

Deep Dive: Building a CLI Tool and HTTP Server

Building a CLI Tool

Writing CLI scripts is where Deno really shines. Your colleagues don’t need Node, npm, or any runtime installed — you compile to a single binary and ship it. That alone saves a lot of “works on my machine” headaches.

Here’s a CLI tool that reads a file and counts word frequency:

// wordcount.ts
const args = Deno.args;

if (args.length === 0) {
  console.error("Usage: deno run --allow-read wordcount.ts <filename>");
  Deno.exit(1);
}

const filename = args[0];
const text = await Deno.readTextFile(filename);

const words = text.toLowerCase().match(/\b\w+\b/g) ?? [];
const freq: Record<string, number> = {};

for (const word of words) {
  freq[word] = (freq[word] ?? 0) + 1;
}

const sorted = Object.entries(freq)
  .sort((a, b) => b[1] - a[1])
  .slice(0, 10);

console.log("Top 10 words:");
for (const [word, count] of sorted) {
  console.log(`  ${word}: ${count}`);
}
deno run --allow-read wordcount.ts README.md

The --allow-read flag is intentional. Deno’s security model is explicit: scripts can’t touch files, network, or environment variables unless you grant it. That might feel clunky on day one. But when you’re running a third-party script in production and want to confirm it can’t phone home or modify your filesystem, you’ll appreciate having a concrete list of what it’s allowed to do.

Compile to a Single Binary

Once your CLI tool works, compile it into a standalone executable. The output is typically 60–80 MB for a simple tool — that’s the Deno runtime bundled in.

# Compile for current platform
deno compile --allow-read wordcount.ts -o wordcount

# Cross-compile for other platforms
deno compile --target x86_64-pc-windows-msvc --allow-read wordcount.ts -o wordcount.exe
deno compile --target x86_64-apple-darwin --allow-read wordcount.ts -o wordcount-mac
deno compile --target x86_64-unknown-linux-gnu --allow-read wordcount.ts -o wordcount-linux

Drop that binary on any machine — no runtime needed.

Building an HTTP Server

Deno 2.0 ships with a production-ready HTTP server. For many use cases, you don’t need Express or Fastify at all:

// server.ts
const PORT = 8000;

const handler = (req: Request): Response => {
  const url = new URL(req.url);

  if (url.pathname === "/") {
    return new Response("Welcome to my Deno server!", {
      headers: { "Content-Type": "text/plain" },
    });
  }

  if (url.pathname === "/health") {
    return Response.json({ status: "ok", timestamp: new Date().toISOString() });
  }

  if (url.pathname === "/echo" && req.method === "POST") {
    const body = req.body;
    return new Response(body, {
      headers: { "Content-Type": req.headers.get("Content-Type") ?? "text/plain" },
    });
  }

  return new Response("Not Found", { status: 404 });
};

console.log(`Server running at http://localhost:${PORT}`);
Deno.serve({ port: PORT }, handler);
deno run --allow-net server.ts

# Test it
curl http://localhost:8000/health
# {"status":"ok","timestamp":"2026-05-21T10:00:00.000Z"}

The handler uses standard Request and Response objects — the exact same Web APIs your browser exposes. If you’ve written any Fetch API code before, this will feel familiar immediately.

Advanced Usage: Integrating npm Packages

Using npm Packages Directly

Deno 2.0 treats npm as a first-class citizen. You can import packages using the npm: specifier, no node_modules directory involved:

// Using npm packages with npm: specifier
import express from "npm:express@4";
import chalk from "npm:chalk@5";

const app = express();

app.get("/", (req, res) => {
  console.log(chalk.green("Request received!"));
  res.json({ message: "Hello from Deno + Express!" });
});

app.listen(3000, () => {
  console.log(chalk.blue("Server running on port 3000"));
});
deno run --allow-net --allow-read --allow-env app.ts

Setting Up a deno.json Config File

Real projects benefit from a deno.json — it centralizes your import aliases and task scripts, similar to package.json:

{
  "imports": {
    "@std/fs": "jsr:@std/fs@1",
    "@std/path": "jsr:@std/path@1",
    "zod": "npm:zod@3",
    "chalk": "npm:chalk@5"
  },
  "tasks": {
    "start": "deno run --allow-net --allow-read server.ts",
    "dev": "deno run --watch --allow-net --allow-read server.ts",
    "build": "deno compile --allow-net --allow-read server.ts -o myserver",
    "test": "deno test --allow-read"
  },
  "compilerOptions": {
    "strict": true
  }
}

Run tasks the same way you’d use npm scripts:

deno task dev    # development with hot reload
deno task build  # compile to binary
deno task test   # run tests

A Practical Example: Validated API with Zod

Here’s a more complete server using Zod for input validation. This is closer to what you’d actually deploy:

// api.ts
import { z } from "zod";

const UserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().min(18),
});

const handler = async (req: Request): Promise<Response> => {
  if (req.method === "POST" && new URL(req.url).pathname === "/users") {
    try {
      const body = await req.json();
      const user = UserSchema.parse(body);
      // In a real app: save to database here
      return Response.json({ success: true, user }, { status: 201 });
    } catch (err) {
      if (err instanceof z.ZodError) {
        return Response.json(
          { success: false, errors: err.errors },
          { status: 400 }
        );
      }
      return Response.json({ success: false, message: "Invalid JSON" }, { status: 400 });
    }
  }

  return new Response("Not Found", { status: 404 });
};

Deno.serve({ port: 8000 }, handler);
# Valid request
curl -X POST http://localhost:8000/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice", "email": "[email protected]", "age": 25}'

# Invalid request — Zod catches it
curl -X POST http://localhost:8000/users \
  -H "Content-Type: application/json" \
  -d '{"name": "", "email": "not-an-email", "age": 15}'

Practical Tips

Use –watch for Development

The --watch flag restarts your script on file changes. No nodemon, no extra install:

deno run --watch --allow-net server.ts

Understand the Permission Model Early

During early development, use --allow-all so you’re not guessing flags. Then lock things down before deploying:

# Development: open permissions
deno run --allow-all server.ts

# Production: only what's needed
deno run --allow-net=:8000 --allow-read=./data server.ts

Testing Is Built In

Deno ships with its own test runner. No Jest, no Mocha, no config file needed:

// wordcount_test.ts
import { assertEquals } from "jsr:@std/assert";

Deno.test("counts words correctly", () => {
  const text = "hello world hello";
  const words = text.match(/\b\w+\b/g) ?? [];
  const freq: Record<string, number> = {};
  for (const word of words) freq[word] = (freq[word] ?? 0) + 1;
  assertEquals(freq["hello"], 2);
  assertEquals(freq["world"], 1);
});
deno test wordcount_test.ts

Cache Dependencies for Offline Use

Deno downloads dependencies on first run and caches them locally. For CI pipelines or restricted environments, pre-cache everything upfront:

# Install and cache all dependencies from deno.json
deno install

# Or cache a specific entry file with a lockfile
deno cache --lock=deno.lock server.ts

Deploying to Production

Your compiled binary runs on any Linux server — Deno itself is not required on the target machine:

# Build
deno compile --target x86_64-unknown-linux-gnu \
  --allow-net --allow-read \
  server.ts -o myserver

# Deploy
scp myserver user@your-server:/opt/myapp/
ssh user@your-server '/opt/myapp/myserver'

Alternatively, push to GitHub and let Deno Deploy handle the rest — no server config, no Docker, just a live URL.

Coming from Node.js, expect roughly a week to feel fully comfortable. The explicit permissions feel like friction at first. After a few days they become second nature, and you start appreciating that every script you review has a clear declaration of what it can access. Start small — build one internal CLI tool. That single project will teach you 80% of what you need to know about how Deno fits together.

Share: