Desktop App · Intermediate
Build a Desktop Notes App
Electron + vanilla JavaScript. Real windows, menus, file system access. Runs on Mac, Windows, and Linux from one codebase.
What you'll build: A real desktop app — not a browser tab. It has its own window, its own menu, and can read/write files on your machine. The same code runs on Mac, Windows, and Linux.
What you'll learn: The Electron architecture (main process + renderer process), IPC communication, native file dialogs, and how to build an app you can actually install and share.
📋 Before you start
You need Node.js 18+ installed. Check with:
node --version # Should print v18 or higher
npm --version # Should print 9 or higher
If you don't have Node.js, install it from nodejs.org — it takes 3 minutes.
The project structure
notes-app/
├── package.json ← Project metadata + dependencies
├── main.js ← Main process (Node.js side)
├── preload.js ← Secure bridge between processes
├── index.html ← UI shell
├── renderer.js ← Renderer process (UI logic)
└── style.css ← Styles
Initialize the project
Create the folder and install Electron.
mkdir notes-app
cd notes-app
npm init -y
npm install --save-dev electron
Update package.json to point to the main file and add a start script:
{
"name": "notes-app",
"version": "1.0.0",
"main": "main.js",
"scripts": {
"start": "electron ."
},
"devDependencies": {
"electron": "^33.0.0"
}
}
main.js — the main process
This is the Node.js side. It creates the window and handles native actions (like opening files).
main.jsconst { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');
const fs = require('fs');
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 900,
height: 650,
minWidth: 600,
minHeight: 400,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
});
mainWindow.loadFile('index.html');
}
// When the renderer asks to save a note
ipcMain.handle('save-note', async (event, content) => {
const { filePath } = await dialog.showSaveDialog(mainWindow, {
defaultPath: 'note.md',
filters: [{ name: 'Markdown', extensions: ['md', 'txt'] }]
});
if (!filePath) return { saved: false };
fs.writeFileSync(filePath, content, 'utf-8');
return { saved: true, path: filePath };
});
// When the renderer asks to open a note
ipcMain.handle('open-note', async () => {
const { filePaths } = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: [{ name: 'Markdown', extensions: ['md', 'txt'] }]
});
if (!filePaths || filePaths.length === 0) return { opened: false };
const content = fs.readFileSync(filePaths[0], 'utf-8');
return { opened: true, content, path: filePaths[0] };
});
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
Why contextIsolation: true? It's the secure default. The renderer can't touch Node.js directly — it must ask via IPC. Never disable this.
preload.js — the secure bridge
The preload script exposes a safe API to the renderer. It's the only way to talk to the main process.
preload.jsconst { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('notesAPI', {
save: (content) => ipcRenderer.invoke('save-note', content),
open: () => ipcRenderer.invoke('open-note')
});
Now the renderer can call window.notesAPI.save(text) and window.notesAPI.open() — nothing else.
index.html — the UI
index.html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Notes</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="sidebar-header">
<h1>Notes</h1>
<button id="newBtn">+</button>
</div>
<ul id="notesList"></ul>
</aside>
<main class="editor">
<div class="editor-toolbar">
<button id="openBtn">Open</button>
<button id="saveBtn">Save</button>
</div>
<textarea id="editor" placeholder="Start typing..."></textarea>
</main>
</div>
<script src="renderer.js"></script>
</body>
</html>
renderer.js — the UI logic
This runs in the browser context. It handles the interface and calls the preload API for file operations.
renderer.jsconst editor = document.getElementById('editor');
const openBtn = document.getElementById('openBtn');
const saveBtn = document.getElementById('saveBtn');
const newBtn = document.getElementById('newBtn');
const notesList = document.getElementById('notesList');
let notes = JSON.parse(localStorage.getItem('notes') || '[]');
let activeId = null;
function renderList() {
notesList.innerHTML = '';
notes.forEach(note => {
const li = document.createElement('li');
li.textContent = note.title || 'Untitled';
if (note.id === activeId) li.classList.add('active');
li.addEventListener('click', () => selectNote(note.id));
notesList.appendChild(li);
});
}
function selectNote(id) {
const note = notes.find(n => n.id === id);
if (!note) return;
activeId = id;
editor.value = note.content;
renderList();
}
function saveCurrent() {
if (activeId === null) return;
const note = notes.find(n => n.id === activeId);
if (!note) return;
note.content = editor.value;
note.title = editor.value.split('\n')[0].slice(0, 40) || 'Untitled';
localStorage.setItem('notes', JSON.stringify(notes));
renderList();
}
newBtn.addEventListener('click', () => {
const note = { id: Date.now(), title: 'Untitled', content: '' };
notes.unshift(note);
activeId = note.id;
editor.value = '';
localStorage.setItem('notes', JSON.stringify(notes));
renderList();
editor.focus();
});
editor.addEventListener('input', () => {
clearTimeout(window._saveTimer);
window._saveTimer = setTimeout(saveCurrent, 500);
});
saveBtn.addEventListener('click', async () => {
const result = await window.notesAPI.save(editor.value);
if (result.saved) console.log('Saved to', result.path);
});
openBtn.addEventListener('click', async () => {
const result = await window.notesAPI.open();
if (result.opened) {
editor.value = result.content;
const note = { id: Date.now(), title: result.path.split('/').pop(), content: result.content };
notes.unshift(note);
activeId = note.id;
localStorage.setItem('notes', JSON.stringify(notes));
renderList();
}
});
renderList();
style.css — the interface
style.css* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, sans-serif;
background: #0f172a;
color: #e2e8f0;
height: 100vh;
overflow: hidden;
}
.app {
display: flex;
height: 100vh;
}
.sidebar {
width: 220px;
background: #1e293b;
border-right: 1px solid #334155;
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #334155;
}
.sidebar-header h1 {
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
}
#newBtn {
background: #8b5cf6;
color: #fff;
border: none;
width: 24px;
height: 24px;
border-radius: 0.375rem;
font-size: 1rem;
cursor: pointer;
font-weight: 700;
}
#notesList {
list-style: none;
overflow-y: auto;
flex: 1;
}
#notesList li {
padding: 0.65rem 1rem;
font-size: 0.85rem;
cursor: pointer;
color: #94a3b8;
border-bottom: 1px solid #1e293b;
}
#notesList li:hover { background: #334155; color: #fff; }
#notesList li.active { background: #334155; color: #fff; font-weight: 600; }
.editor {
flex: 1;
display: flex;
flex-direction: column;
}
.editor-toolbar {
padding: 0.6rem 1rem;
display: flex;
gap: 0.5rem;
border-bottom: 1px solid #334155;
}
.editor-toolbar button {
background: #1e293b;
color: #cbd5e1;
border: 1px solid #334155;
padding: 0.4rem 0.85rem;
border-radius: 0.375rem;
font-size: 0.78rem;
cursor: pointer;
font-family: inherit;
}
.editor-toolbar button:hover { background: #334155; color: #fff; }
#editor {
flex: 1;
background: #0f172a;
color: #e2e8f0;
border: none;
outline: none;
padding: 1.5rem 2rem;
font-size: 1rem;
line-height: 1.6;
resize: none;
font-family: inherit;
}
#editor::placeholder { color: #64748b; }
Run the app
npm start
A native window opens with your notes app. Click + to create a note. Type in the editor. It autosaves to localStorage. Click Save to write it to a real file on your machine.
How Electron's architecture works
This is the part that trips up most beginners. Electron runs two separate processes, each with different powers.
Full Node.js access. Can read/write files, show native dialogs, create windows, spawn processes. It's the "operating system" side.
Runs in a Chromium browser context. Has full DOM access. Has no Node.js access by default. It's the "user interface" side.
A small, safe script that bridges the two. It uses contextBridge to expose specific functions to the renderer. Anything not exposed here is off-limits.
The mental model: "Renderer asks, Main does." Every native action must be requested via IPC. The renderer can't just open a file — it has to ask the main process to open a dialog.
⚠️ Common problems
You tried to use Node.js inside the renderer. That's blocked by default. Use the preload API instead — window.notesAPI.
Your preload path is wrong, or contextIsolation is off. Check that preload: path.join(__dirname, 'preload.js') is present in main.js.
Path issue in loadFile(). Confirm index.html is in the same folder as main.js.
Set the mirror: npm config set ELECTRON_MIRROR https://npmmirror.com/mirrors/electron/;. Then npm install again.
📦 Building for distribution
To create installable apps for Mac, Windows, and Linux, add electron-builder:
npm install --save-dev electron-builder
# Add to package.json:
{
"scripts": {
"start": "electron .",
"build": "electron-builder",
"build:mac": "electron-builder --mac",
"build:win": "electron-builder --win",
"build:linux": "electron-builder --linux"
},
"build": {
"appId": "com.yourname.notes",
"files": ["main.js", "preload.js", "index.html", "renderer.js", "style.css"]
}
}
Then run npm run build to produce installers in the dist/ folder. The result is a real app you can install and share with others.
🎯 What to add next
- Markdown preview. Render markdown side-by-side using marked.js.
- Search. Add a search bar that filters notes by content.
- Auto-save to disk. Watch for changes and write to a file after 2 seconds idle.
- Tabs. Open multiple notes in tabs like VS Code.
- Native menu bar. Add File → New, File → Save via
Menu.buildFromTemplate(). - System tray. Minimize to tray and reopen with a global shortcut.
❓ Frequently asked questions
What is Electron?
Electron is a framework that lets you build cross-platform desktop apps using HTML, CSS, and JavaScript. Apps like VS Code, Slack, Discord, and Notion are built with Electron.
Do I need to know Node.js to build an Electron app?
You need basic Node.js knowledge to install packages and run commands. You don't need to write server-side Node.js. Most Electron code is HTML, CSS, and browser JavaScript.
Is the app really cross-platform?
Yes. The same codebase runs on macOS, Windows, and Linux. You build once and package it three times (or use electron-builder to do all three at once).
How big is an Electron app?
Bundled Electron apps are typically 80-150 MB because they include a full Chromium browser and Node.js runtime. This is the trade-off for cross-platform consistency. For smaller binaries, use Tauri instead.
What is the difference between Electron and Tauri?
Electron bundles Chromium and Node.js. Tauri uses the OS native webview and Rust. Tauri apps are smaller (2-10 MB) but require Rust for the backend. Electron has a bigger community, more tutorials, and works with pure JavaScript.