Deno 2.9 (released June 25, 2026) ships one of the most anticipated features in the JavaScript runtime ecosystem: deno desktop, an experimental command that turns any web project into a native, distributable desktop application. No Electron. No Tauri config files. Just TypeScript.
In this tutorial you will build a fully functional Markdown Note-taking desktop app — a web frontend served by Deno.serve(), bridged to the operating system through the Deno.BrowserWindow API, with a native menu bar and tray icon, compiled to a single binary for macOS, Windows, and Linux.
Prerequisites
Before starting, ensure you have:
- Deno 2.9+ installed (
deno upgradeif you have an older version) - Basic knowledge of TypeScript and modern JavaScript
- Familiarity with either Fresh, Astro, or vanilla Deno HTTP servers
- A code editor (VS Code with the Deno extension is recommended)
What You'll Build
By the end of this tutorial you will have:
- A note-taking desktop app with a webview UI
- A TypeScript backend running in Deno communicating with the UI via
window.bind() - A native menu bar with keyboard shortcuts
- A system tray icon with a panel
- A single-file distributable binary for macOS, Windows, and Linux
Step 1: Install or Upgrade Deno 2.9
If Deno is not yet installed, run:
curl -fsSL https://deno.land/install.sh | shIf you already have Deno, upgrade to 2.9:
deno upgrade
deno --version
# deno 2.9.0deno desktop is marked experimental in Deno 2.9. The API surface is stabilising rapidly but minor breaking changes may still occur between patch releases.
Step 2: Bootstrap the Project
Create the project directory and the main entrypoint:
mkdir deno-notes-app
cd deno-notes-appCreate deno.json (the Deno workspace config):
{
"name": "deno-notes",
"version": "1.0.0",
"tasks": {
"dev": "deno desktop .",
"build": "deno desktop build --all-targets ."
},
"permissions": {
"read": true,
"write": true,
"net": ["localhost"]
}
}Step 3: Build the Desktop Entrypoint
The desktop entrypoint is the TypeScript file that runs in the Deno process — not in the browser. It controls windows, system menus, and native OS features.
Create main.ts:
// Desktop entrypoint — runs in Deno, not in the webview
const win = new Deno.BrowserWindow({
title: "Deno Notes",
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
});
// Serve the UI
const server = Deno.serve({ port: 8000 }, async (req) => {
const url = new URL(req.url);
const filePath = url.pathname === "/" ? "/index.html" : url.pathname;
try {
const file = await Deno.readFile(`./public${filePath}`);
const ext = filePath.split(".").pop() ?? "txt";
const types: Record<string, string> = {
html: "text/html",
css: "text/css",
js: "application/javascript",
json: "application/json",
};
return new Response(file, {
headers: { "Content-Type": types[ext] ?? "text/plain" },
});
} catch {
return new Response("Not found", { status: 404 });
}
});
// Open the webview pointing at the local server
win.loadURL("http://localhost:8000");
// Bind Deno functions that the webview can call
win.bind("saveNote", async (id: string, content: string) => {
const path = `./notes/${id}.md`;
await Deno.mkdir("./notes", { recursive: true });
await Deno.writeTextFile(path, content);
return { ok: true };
});
win.bind("loadNote", async (id: string) => {
try {
const content = await Deno.readTextFile(`./notes/${id}.md`);
return { ok: true, content };
} catch {
return { ok: false, content: "" };
}
});
win.bind("listNotes", async () => {
try {
const entries = [];
for await (const entry of Deno.readDir("./notes")) {
if (entry.isFile && entry.name.endsWith(".md")) {
entries.push(entry.name.replace(".md", ""));
}
}
return { ok: true, notes: entries };
} catch {
return { ok: false, notes: [] };
}
});
win.bind("deleteNote", async (id: string) => {
await Deno.remove(`./notes/${id}.md`);
return { ok: true };
});
// Handle window close
win.addEventListener("close", () => {
server.shutdown();
Deno.exit(0);
});Deno.BrowserWindow uses the operating system's native webview by default (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux), keeping binaries small. Add { backend: "chromium" } to the options if you need pixel-perfect rendering across all platforms.
Step 4: Create the Web UI
Create the public/ directory and public/index.html:
mkdir publicCreate public/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Deno Notes</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<aside id="sidebar">
<div class="sidebar-header">
<h2>Notes</h2>
<button id="newNote">+ New</button>
</div>
<ul id="noteList"></ul>
</aside>
<main id="editor">
<input id="noteTitle" type="text" placeholder="Note title..." />
<textarea id="noteContent" placeholder="Start writing in Markdown..."></textarea>
<div class="toolbar">
<button id="saveBtn">Save</button>
<button id="deleteBtn">Delete</button>
</div>
</main>
<script src="/app.js"></script>
</body>
</html>Create public/styles.css:
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
display: flex;
height: 100vh;
background: #1a1a2e;
color: #e0e0e0;
}
#sidebar {
width: 260px;
background: #16213e;
border-right: 1px solid #0f3460;
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #0f3460;
}
#newNote {
background: #533483;
color: white;
border: none;
padding: 0.4rem 0.8rem;
border-radius: 6px;
cursor: pointer;
}
#noteList { list-style: none; overflow-y: auto; flex: 1; }
#noteList li {
padding: 0.75rem 1rem;
cursor: pointer;
border-bottom: 1px solid #0f3460;
font-size: 0.9rem;
}
#noteList li:hover, #noteList li.active { background: #0f3460; }
#editor {
flex: 1;
display: flex;
flex-direction: column;
padding: 1rem;
gap: 0.75rem;
}
#noteTitle {
font-size: 1.4rem;
font-weight: 600;
background: transparent;
border: none;
border-bottom: 2px solid #533483;
color: #e0e0e0;
padding: 0.5rem 0;
outline: none;
}
#noteContent {
flex: 1;
background: #16213e;
color: #e0e0e0;
border: 1px solid #0f3460;
border-radius: 8px;
padding: 1rem;
font-family: "Fira Code", monospace;
font-size: 0.95rem;
resize: none;
outline: none;
}
.toolbar { display: flex; gap: 0.5rem; }
.toolbar button {
padding: 0.5rem 1.5rem;
border-radius: 6px;
border: none;
cursor: pointer;
font-weight: 600;
}
#saveBtn { background: #533483; color: white; }
#deleteBtn { background: #c0392b; color: white; }Create public/app.js:
let currentNote = null;
async function loadNoteList() {
const result = await bindings.listNotes();
const ul = document.getElementById("noteList");
ul.innerHTML = "";
for (const id of result.notes) {
const li = document.createElement("li");
li.textContent = id;
li.dataset.id = id;
if (id === currentNote) li.classList.add("active");
li.addEventListener("click", () => openNote(id));
ul.appendChild(li);
}
}
async function openNote(id) {
currentNote = id;
const result = await bindings.loadNote(id);
document.getElementById("noteTitle").value = id;
document.getElementById("noteContent").value = result.content;
document.querySelectorAll("#noteList li").forEach((li) => {
li.classList.toggle("active", li.dataset.id === id);
});
}
document.getElementById("newNote").addEventListener("click", () => {
const id = "note-" + Date.now();
currentNote = id;
document.getElementById("noteTitle").value = id;
document.getElementById("noteContent").value = "";
});
document.getElementById("saveBtn").addEventListener("click", async () => {
const id = document.getElementById("noteTitle").value.trim();
const content = document.getElementById("noteContent").value;
if (!id) return alert("Please give the note a title.");
currentNote = id;
await bindings.saveNote(id, content);
await loadNoteList();
});
document.getElementById("deleteBtn").addEventListener("click", async () => {
if (!currentNote) return;
await bindings.deleteNote(currentNote);
currentNote = null;
document.getElementById("noteTitle").value = "";
document.getElementById("noteContent").value = "";
await loadNoteList();
});
loadNoteList();The bindings global is injected by Deno.BrowserWindow automatically. Every function you registered with win.bind() in your entrypoint becomes available by the same name under bindings in the webview. All calls return Promises.
Step 5: Add a Native Menu Bar
Extend main.ts to include a native application menu:
// Add after the win.bind() calls
win.setMenu({
items: [
{
label: "File",
submenu: [
{ id: "new", label: "New Note", accelerator: "CmdOrCtrl+N" },
{ id: "save", label: "Save", accelerator: "CmdOrCtrl+S" },
{ type: "separator" },
{ id: "quit", label: "Quit", accelerator: "CmdOrCtrl+Q" },
],
},
{
label: "Edit",
submenu: [
{ id: "undo", label: "Undo", role: "undo" },
{ id: "redo", label: "Redo", role: "redo" },
{ type: "separator" },
{ id: "cut", label: "Cut", role: "cut" },
{ id: "copy", label: "Copy", role: "copy" },
{ id: "paste", label: "Paste", role: "paste" },
],
},
],
});
win.addEventListener("menuclick", (e) => {
const id = e.detail.id;
if (id === "new") win.executeJs("document.getElementById('newNote').click()");
if (id === "save") win.executeJs("document.getElementById('saveBtn').click()");
if (id === "quit") Deno.exit(0);
});role items like "cut", "copy", "paste", "undo", and "redo" delegate to the operating system's built-in clipboard and undo stack — no JavaScript needed for those.
Step 6: Add a System Tray Icon
Add a tray icon so users can access the app from the menubar/system tray:
// Read a 32x32 PNG icon (you can generate one with any image tool)
const iconPath = new URL("./assets/icon.png", import.meta.url).pathname;
const iconBytes = await Deno.readFile(iconPath);
const tray = new Deno.Tray();
tray.setIcon(iconBytes);
tray.setTooltip("Deno Notes");
tray.setContextMenu({
items: [
{ id: "show", label: "Show Window" },
{ id: "quit", label: "Quit" },
],
});
tray.addEventListener("contextmenuclick", (e) => {
if (e.detail.id === "show") {
win.show();
win.focus();
}
if (e.detail.id === "quit") Deno.exit(0);
});Create assets/ and place a 32x32 PNG there. A simple coloured PNG is enough for testing.
Step 7: Run in Development Mode
deno task dev
# Equivalent to: deno desktop .Deno auto-detects the entrypoint from deno.json and opens the window. Hot-reload is not built in yet — restart deno task dev after changing main.ts, but changes to public/ are picked up on browser refresh.
During development, open DevTools inside the webview with win.openDevTools() in your entrypoint, or by adding the --inspect flag to deno desktop.
Step 8: Build and Package for Distribution
Build a native binary for your current platform:
deno desktop build .
# Outputs: ./dist/deno-notes.app (macOS)
# ./dist/deno-notes.exe (Windows)
# ./dist/deno-notes.AppImage (Linux)Cross-compile for all platforms in one command:
deno desktop build --all-targets .Build a specific format:
# macOS disk image
deno desktop build --output dist/DenoNotes.dmg .
# Windows installer
deno desktop build --output dist/DenoNotes.msi .
# Debian package
deno desktop build --output dist/deno-notes.deb .Cross-compilation works out of the box on any CI runner — you do not need a macOS machine to build the .dmg. Deno bundles the target platform toolchain automatically.
Project Structure
Your finished project should look like this:
deno-notes-app/
├── deno.json
├── main.ts # Desktop entrypoint (Deno process)
├── assets/
│ └── icon.png
├── notes/ # Created at runtime
└── public/
├── index.html
├── styles.css
└── app.js
How the Bridge Works
Understanding the communication model is key to building more complex apps:
┌─────────────────────────────────────────┐
│ Deno Process (main.ts) │
│ │
│ Deno.BrowserWindow │
│ ├─ win.bind("saveNote", fn) ◄──────────┼──── registers native handler
│ ├─ win.executeJs("...") ──────────►│ calls webview JS
│ └─ win.addEventListener(...) │
│ │
└──────────────────────┬───────────────────┘
│ IPC (secure)
┌──────────────────────▼───────────────────┐
│ WebView (public/app.js) │
│ │
│ bindings.saveNote(id, content) ────────┼──► calls Deno handler
│ bindings.loadNote(id) ────────┘
│ │
└──────────────────────────────────────────┘
The IPC channel is sandboxed — the webview cannot access the filesystem directly. All filesystem access must go through bound functions in the Deno entrypoint, which gives you a clean security boundary.
Comparison with Electron and Tauri
| Feature | Deno Desktop | Electron | Tauri |
|---|---|---|---|
| Language | TypeScript | JavaScript | Rust + JS |
| Binary size | ~15 MB | ~80–150 MB | ~5–10 MB |
| Backend | Deno (V8) | Node.js (V8) | Rust |
| Native WebView | Yes (default) | No (Chromium) | Yes |
| Bundled Chromium | Optional | Always | No |
| Cross-compile | Built-in | Via electron-builder | Via Tauri CLI |
| NPM compat | Full (Node compat) | Full | Via JS bridge |
Troubleshooting
Window opens but shows a blank white screen
Check that your server starts before win.loadURL() is called. Add a small delay or wait for the server ready event.
bindings is not defined in the webview
Make sure win.bind() is called before win.loadURL(). The binding is injected into the page on load.
Build fails with DENO_DESKTOP_UNSTABLE error
The deno desktop command requires --unstable-desktop flag if you are on a Deno version earlier than 2.9.0.
Tray icon not showing on Linux
You need libayatana-appindicator3-1 installed: sudo apt install libayatana-appindicator3-1.
Next Steps
Now that you have a working desktop app:
- Add Markdown preview: Render the Markdown to HTML using the Marked library loaded from a CDN
- Use Fresh or Astro: Replace the manual
Deno.serve()with a full framework —deno desktopauto-detects them - Add authentication: Use
Deno.envin your entrypoint to read secrets safely without exposing them to the webview - Publish to app stores: Package as
.pkg(macOS App Store) or.msix(Microsoft Store) with code signing - Explore Deno Windowing: Visit windowing.deno.dev for the extended windowing API with multi-window support
Conclusion
Deno 2.9's deno desktop command lowers the barrier to cross-platform desktop development dramatically. You get a TypeScript-native backend, the full web platform for your UI, a clean security boundary through window.bind(), and zero-config cross-compilation — all without the 150 MB Electron overhead. The API is still experimental and evolving, but the fundamentals are already solid enough to ship real products.
For the latest API surface, refer to the official Deno Desktop documentation.