coderain guide

Building Cross-Platform Desktop Apps with JavaScript and Electron

In an era where users expect applications to work seamlessly across Windows, macOS, and Linux, developers face the challenge of building cross-platform desktop software without rewriting code from scratch. Enter **Electron**—an open-source framework that empowers developers to create native-like desktop applications using familiar web technologies: HTML, CSS, and JavaScript. Developed by GitHub (and now maintained by the OpenJS Foundation), Electron combines the power of Chromium (the engine behind Google Chrome) for rendering web content with Node.js for backend functionality, enabling access to the operating system’s native APIs. This unique blend allows JavaScript developers to build desktop apps with minimal learning curve, while leveraging their existing web development skills. Popular applications like Visual Studio Code, Slack, Discord, and WhatsApp Desktop are all built with Electron, a testament to its versatility and reliability. In this blog, we’ll explore how to use Electron to build, package, and distribute your own cross-platform desktop app, from setup to deployment.

Table of Contents

  1. What is Electron?
  2. Why Choose Electron?
  3. Setting Up Your Development Environment
  4. Building Your First Electron App: A Step-by-Step Guide
  5. Core Concepts: Main vs. Renderer Processes
  6. Packaging and Distributing Your Electron App
  7. Advanced Features
  8. Best Practices for Electron Development
  9. Conclusion
  10. References

What is Electron?

Electron is a framework that enables developers to build cross-platform desktop applications using web technologies. At its core, it consists of three key components:

  • Chromium: Renders the app’s user interface (UI) using HTML, CSS, and JavaScript, ensuring consistent rendering across platforms.
  • Node.js: Provides access to the operating system’s file system, network, and other native APIs via JavaScript.
  • Custom APIs: Electron-specific modules (e.g., app, BrowserWindow) that bridge Chromium and Node.js, enabling window management, app lifecycle control, and more.

By combining these technologies, Electron allows you to write code once and deploy it as a native app for Windows, macOS, and Linux—no need to learn platform-specific languages like C# (Windows), Swift (macOS), or Qt (Linux).

Why Choose Electron?

Electron has gained widespread adoption for several reasons:

  • Cross-Platform Simplicity: Build once, run everywhere (Windows, macOS, Linux) with minimal platform-specific adjustments.
  • Familiar Web Stack: Use HTML, CSS, and JavaScript—skills many developers already possess—instead of learning new languages.
  • Rich Ecosystem: Access thousands of Node.js packages and web libraries (React, Vue, Angular, etc.) to accelerate development.
  • Native Capabilities: Control windows, menus, tray icons, file systems, and hardware via Electron’s APIs.
  • Active Community: Backed by GitHub and a large community, ensuring regular updates, bug fixes, and extensive documentation.

Note: Electron apps can be larger in size than native apps (due to bundling Chromium) and may have slightly higher resource usage. However, these trade-offs are often acceptable for the speed and simplicity of development.

Setting Up Your Development Environment

Before building your first Electron app, ensure you have the following tools installed:

  • Node.js: Electron requires Node.js (v14 or later) and npm (Node Package Manager). Download it from nodejs.org.
  • Code Editor: Use VS Code (ironically, an Electron app!), Sublime Text, or any editor of your choice.

Verify your setup by running these commands in your terminal:

node -v   # Should output v14.x.x or higher  
npm -v    # Should output 6.x.x or higher  

Building Your First Electron App: A Step-by-Step Guide

Let’s create a simple “Hello World” app to understand Electron’s basics. We’ll build a window that displays a message and includes a button to interact with the main process.

Project Structure

First, create a new project folder and initialize it with npm:

mkdir electron-hello-world  
cd electron-hello-world  
npm init -y  

This creates a package.json file, which manages dependencies and app configuration.

Your project will need three key files:

electron-hello-world/  
├── main.js          # Main process (controls app lifecycle)  
├── index.html       # Renderer process (UI)  
└── package.json     # App metadata and scripts  

The Main Process (main.js)

The main.js file is the entry point of your Electron app. It controls the main process, which manages windows, app lifecycle, and native integrations.

Add this code to main.js:

// Import Electron modules  
const { app, BrowserWindow } = require('electron');  
const path = require('path');  

// Create a window when the app is ready  
function createWindow() {  
  // Configure the window  
  const mainWindow = new BrowserWindow({  
    width: 800,          // Window width  
    height: 600,         // Window height  
    title: "My First Electron App",  
    webPreferences: {  
      nodeIntegration: false,  // Disable Node.js in renderer (security best practice)  
      contextIsolation: true,  // Isolate renderer context (security)  
      preload: path.join(__dirname, 'preload.js')  // Preload script (see note below)  
    }  
  });  

  // Load the HTML file into the window  
  mainWindow.loadFile('index.html');  

  // Open DevTools (for debugging)  
  mainWindow.webContents.openDevTools();  
}  

// Electron is ready to create windows  
app.whenReady().then(() => {  
  createWindow();  

  // On macOS, re-create a window if the dock icon is clicked and no windows exist  
  app.on('activate', () => {  
    if (BrowserWindow.getAllWindows().length === 0) createWindow();  
  });  
});  

// Quit the app when all windows are closed (except on macOS)  
app.on('window-all-closed', () => {  
  if (process.platform !== 'darwin') app.quit();  
});  

Note: preload.js is a script that runs in the renderer process before the page loads, allowing limited access to Node.js APIs (if needed) while maintaining security. For now, create an empty preload.js file in your project root.

The Renderer Process (index.html)

The renderer process handles the app’s UI. Create index.html with this content:

<!DOCTYPE html>  
<html>  
  <head>  
    <meta charset="UTF-8">  
    <title>Hello Electron!</title>  
    <style>  
      body { font-family: Arial, sans-serif; text-align: center; padding: 2rem; }  
      button { padding: 0.5rem 1rem; font-size: 1rem; cursor: pointer; }  
    </style>  
  </head>  
  <body>  
    <h1>Hello, Electron!</h1>  
    <p>This is my first cross-platform desktop app.</p>  
    <button id="greetBtn">Click Me!</button>  

    <script>  
      // Renderer process logic  
      document.getElementById('greetBtn').addEventListener('click', () => {  
        alert('Hello from the renderer process!');  
      });  
    </script>  
  </body>  
</html>  

Configuring package.json

Update package.json to define your app’s entry point and add scripts to run it. Replace the default content with:

{  
  "name": "electron-hello-world",  
  "version": "1.0.0",  
  "description": "A simple Electron app",  
  "main": "main.js",  // Entry point for the main process  
  "scripts": {  
    "start": "electron ."  // Command to run the app  
  },  
  "keywords": ["electron", "desktop", "javascript"],  
  "author": "Your Name",  
  "license": "MIT",  
  "devDependencies": {  
    "electron": "^28.0.0"  // Use the latest stable Electron version  
  }  
}  

Install Electron as a dev dependency:

npm install electron --save-dev  

Running the App

Start the app with:

npm start  

You should see a window with your “Hello, Electron!” message and a button. Clicking the button triggers an alert—your first Electron app is working!

Core Concepts: Main vs. Renderer Processes

Electron apps have two types of processes, each with distinct roles:

Main Process

  • Purpose: Manages app lifecycle (start, quit), window creation, and native integrations (menus, tray icons, file system access).
  • Entry Point: Defined by the "main" field in package.json (e.g., main.js).
  • APIs: Access to Electron’s core modules like app, BrowserWindow, and Menu.

Renderer Process

  • Purpose: Renders the UI using HTML, CSS, and JavaScript (like a web page). Each window in your app runs its own renderer process.
  • Entry Point: The HTML file loaded by BrowserWindow (e.g., index.html).
  • APIs: Limited to web APIs by default (e.g., DOM, fetch), but can access Node.js/Electron APIs via IPC (see below).

Inter-Process Communication (IPC)

Since main and renderer processes are isolated, they communicate via IPC (Inter-Process Communication). Electron provides ipcMain (main process) and ipcRenderer (renderer process) modules for this.

Example: Send a message from renderer to main process

  1. Update preload.js to expose IPC functionality safely (required for contextIsolation: true):
// preload.js  
const { contextBridge, ipcRenderer } = require('electron');  

// Expose a safe API to the renderer process  
contextBridge.exposeInMainWorld('electronAPI', {  
  sendMessage: (message) => ipcRenderer.send('message-from-renderer', message),  
  onReply: (callback) => ipcRenderer.on('reply-from-main', (event, arg) => callback(arg))  
});  
  1. Update main.js to listen for messages and reply:
// Add this to main.js (after creating the window)  
const { ipcMain } = require('electron');  

ipcMain.on('message-from-renderer', (event, message) => {  
  console.log('Main process received:', message);  
  event.reply('reply-from-main', 'Message received! Thanks from main.');  
});  
  1. Update index.html to use the exposed API:
<!-- Replace the button click handler in index.html -->  
<script>  
  document.getElementById('greetBtn').addEventListener('click', () => {  
    // Send a message to the main process  
    window.electronAPI.sendMessage('Hello from renderer!');  

    // Listen for a reply  
    window.electronAPI.onReply((reply) => {  
      alert(reply);  // Shows "Message received! Thanks from main."  
    });  
  });  
</script>  

Run npm start again. Clicking the button now sends a message to the main process, which replies—demonstrating IPC!

Packaging and Distributing Your Electron App

Once your app is ready, package it into a native installer (e.g., .exe for Windows, .dmg for macOS, .deb for Linux). We’ll use electron-builder, a popular tool for packaging.

Using electron-builder

Install electron-builder as a dev dependency:

npm install electron-builder --save-dev  

Update package.json to add packaging scripts:

"scripts": {  
  "start": "electron .",  
  "package": "electron-builder"  // Packages for the current platform  
}  

Add a "build" section to package.json to configure packaging:

"build": {  
  "appId": "com.yourname.electronhelloworld",  // Unique ID (reverse domain format)  
  "productName": "HelloElectron",  // App name  
  "directories": {  
    "output": "dist"  // Where to save packaged files  
  },  
  "win": {  
    "target": "nsis"  // Windows installer (NSIS)  
  },  
  "mac": {  
    "target": "dmg"   // macOS disk image  
  },  
  "linux": {  
    "target": "deb"   // Linux Debian package  
  }  
}  

Targeting Multiple Platforms

To package for a specific platform (even if you’re on another OS), use these commands:

# Package for Windows (from macOS/Linux)  
npm run package -- --win  

# Package for macOS (from Windows/Linux, requires macOS dependencies)  
npm run package -- --mac  

# Package for Linux (from Windows/macOS)  
npm run package -- --linux  

Packaged apps will appear in the dist folder.

Advanced Features

Native Menus and Tray Icons

Electron lets you add native menus (top bar on macOS, window bar on Windows) and tray icons (system tray).

Example: Add a Menu

Update main.js to create a menu:

const { Menu } = require('electron');  

const menuTemplate = [  
  {  
    label: 'File',  
    submenu: [  
      { label: 'Exit', click: () => app.quit() }  
    ]  
  },  
  {  
    label: 'Help',  
    submenu: [  
      { label: 'About', click: () => alert('HelloElectron v1.0') }  
    ]  
  }  
];  

const menu = Menu.buildFromTemplate(menuTemplate);  
Menu.setApplicationMenu(menu);  

Auto-Updates

Keep users on the latest version with electron-updater (built into electron-builder). Configure it by adding a "publish" field to package.json:

"build": {  
  "publish": {  
    "provider": "github",  // Publish updates to GitHub Releases  
    "repo": "electron-hello-world",  // Your GitHub repo  
    "owner": "your-github-username"  
  }  
}  

Debugging Tools

  • Renderer Process: Use Chrome DevTools (Ctrl+Shift+I or Cmd+Opt+I in the app window).
  • Main Process: Add --inspect=5858 to the start script: "start": "electron --inspect=5858 .", then debug in Chrome at chrome://inspect.

Best Practices for Electron Development

  • Security First:
    • Enable contextIsolation: true and sandbox: true in BrowserWindow to isolate the renderer process.
    • Avoid nodeIntegration: true (unsafe) unless absolutely necessary. Use IPC instead.
  • Optimize Performance:
    • Minimize renderer processes (each window is a renderer).
    • Use webContents.on('did-finish-load') to delay heavy tasks until the page loads.
  • Code Structure:
    • Separate main/renderer logic into distinct files.
    • Use TypeScript for type safety in larger apps.
  • Testing:
    • Use spectron (Electron’s official testing framework) for end-to-end tests.

Conclusion

Electron simplifies cross-platform desktop app development by leveraging web technologies. With its powerful APIs, active community, and seamless integration of Chromium and Node.js, you can build feature-rich apps for Windows, macOS, and Linux in record time.

Start small (like our “Hello World” app), experiment with IPC and native features, and gradually scale up. The possibilities are endless—from productivity tools to media players, Electron empowers you to turn web skills into desktop solutions.

References