Build Project

Learn by Building

Home › Build Projects › Desktop Apps › Notes App (Electron)

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.

⏱️ 90 minutes · 📄 4 files · 🎯 Intermediate

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.

Notes App
Notes
Meeting notes
Shopping list
Project ideas
Meeting notes
Discussed Q4 roadmap and hiring plan. Prioritized the new onboarding flow.

📋 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
1

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:

package.json
{
  "name": "notes-app",
  "version": "1.0.0",
  "main": "main.js",
  "scripts": {
    "start": "electron ."
  },
  "devDependencies": {
    "electron": "^33.0.0"
  }
}
2

main.js — the main process

This is the Node.js side. It creates the window and handles native actions (like opening files).

main.js
const { 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.

3

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.js
const { 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.

4

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>
5

renderer.js — the UI logic

This runs in the browser context. It handles the interface and calls the preload API for file operations.

renderer.js
const 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();
6

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; }
7

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.

🟪 Main process (main.js)

Full Node.js access. Can read/write files, show native dialogs, create windows, spawn processes. It's the "operating system" side.

🟦 Renderer process (renderer.js)

Runs in a Chromium browser context. Has full DOM access. Has no Node.js access by default. It's the "user interface" side.

🔌 Preload (preload.js)

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

require is not defined

You tried to use Node.js inside the renderer. That's blocked by default. Use the preload API instead — window.notesAPI.

window.notesAPI is undefined

Your preload path is wrong, or contextIsolation is off. Check that preload: path.join(__dirname, 'preload.js') is present in main.js.

App window opens blank

Path issue in loadFile(). Confirm index.html is in the same folder as main.js.

Electron download fails behind a proxy

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

❓ 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.

🔗 Related

← All Build Projects