HOWTOIntermediate

Building Desktop Apps with Electron and Next.js

Create cross-platform desktop applications by combining Electron for native capabilities with Next.js for the UI. Covers IPC communication, system tray,...

Dylan H.

Software Engineering

February 5, 2026
5 min read
Building Desktop Apps with Electron and Next.js

Prerequisites

  • Next.js fundamentals
  • Node.js
  • Basic understanding of desktop app concepts

Overview

Electron lets you build native desktop apps using web technologies. Combined with Next.js, you get server-side rendering, file-based routing, and the React ecosystem in a desktop package. This guide covers the architecture from project setup to distribution.

Architecture

┌─────────────────────────────────────────────┐
│              Electron Shell                  │
│  ┌────────────────────────────────────────┐  │
│  │           Main Process                 │  │
│  │  - Window management                  │  │
│  │  - System tray                        │  │
│  │  - IPC handler                        │  │
│  │  - Native APIs (fs, shell, etc.)      │  │
│  └──────────────┬─────────────────────────┘  │
│                 │ IPC                         │
│  ┌──────────────▼─────────────────────────┐  │
│  │         Renderer Process               │  │
│  │  ┌──────────────────────────────────┐  │  │
│  │  │        Next.js App               │  │  │
│  │  │  - Pages & Components            │  │  │
│  │  │  - React State                   │  │  │
│  │  │  - UI Rendering                  │  │  │
│  │  └──────────────────────────────────┘  │  │
│  └────────────────────────────────────────┘  │
└─────────────────────────────────────────────┘

Requirements

ComponentVersion
Node.js18+
Electron28+
Next.js14+

Process

Step 1: Project Setup

npx create-next-app@latest my-desktop-app --typescript --app
cd my-desktop-app
npm install electron electron-builder concurrently wait-on
npm install -D @types/electron

Step 2: Electron Main Process

// electron/main.ts
import { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage } from "electron";
import path from "path";
 
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
 
const isDev = process.env.NODE_ENV === "development";
 
function createWindow() {
  mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    minWidth: 800,
    minHeight: 600,
    webPreferences: {
      preload: path.join(__dirname, "preload.js"),
      contextIsolation: true,
      nodeIntegration: false,
    },
    titleBarStyle: "hiddenInset",
    backgroundColor: "#0a0a0a",
  });
 
  if (isDev) {
    mainWindow.loadURL("http://localhost:3000");
    mainWindow.webContents.openDevTools();
  } else {
    mainWindow.loadFile(path.join(__dirname, "../out/index.html"));
  }
 
  mainWindow.on("close", (event) => {
    // Minimize to tray instead of closing
    event.preventDefault();
    mainWindow?.hide();
  });
}
 
function createTray() {
  const icon = nativeImage.createFromPath(
    path.join(__dirname, "../assets/tray-icon.png")
  );
  tray = new Tray(icon.resize({ width: 16, height: 16 }));
 
  const contextMenu = Menu.buildFromTemplate([
    { label: "Show App", click: () => mainWindow?.show() },
    { type: "separator" },
    {
      label: "Quit",
      click: () => {
        mainWindow?.destroy();
        app.quit();
      },
    },
  ]);
 
  tray.setContextMenu(contextMenu);
  tray.on("double-click", () => mainWindow?.show());
}
 
app.whenReady().then(() => {
  createWindow();
  createTray();
  registerIpcHandlers();
});

Step 3: Preload Script (Secure Bridge)

// electron/preload.ts
import { contextBridge, ipcRenderer } from "electron";
 
// Expose safe APIs to the renderer
contextBridge.exposeInMainWorld("electronAPI", {
  // File operations
  readFile: (path: string) => ipcRenderer.invoke("fs:read", path),
  writeFile: (path: string, data: string) =>
    ipcRenderer.invoke("fs:write", path, data),
  selectDirectory: () => ipcRenderer.invoke("dialog:selectDirectory"),
 
  // System info
  getPlatform: () => process.platform,
  getVersion: () => ipcRenderer.invoke("app:version"),
 
  // Events from main process
  onUpdateAvailable: (callback: (info: any) => void) =>
    ipcRenderer.on("update-available", (_, info) => callback(info)),
});

Step 4: IPC Handlers

// electron/ipc-handlers.ts
import { ipcMain, dialog, app } from "electron";
import fs from "fs/promises";
import path from "path";
 
export function registerIpcHandlers() {
  // File system operations
  ipcMain.handle("fs:read", async (_, filePath: string) => {
    // Validate path is within allowed directories
    const resolved = path.resolve(filePath);
    const content = await fs.readFile(resolved, "utf-8");
    return content;
  });
 
  ipcMain.handle("fs:write", async (_, filePath: string, data: string) => {
    const resolved = path.resolve(filePath);
    await fs.writeFile(resolved, data, "utf-8");
    return true;
  });
 
  // Native dialogs
  ipcMain.handle("dialog:selectDirectory", async () => {
    const result = await dialog.showOpenDialog({
      properties: ["openDirectory"],
    });
    return result.canceled ? null : result.filePaths[0];
  });
 
  // App info
  ipcMain.handle("app:version", () => app.getVersion());
}

Step 5: Using Electron APIs in React

// hooks/useElectron.ts
"use client";
 
import { useState, useEffect } from "react";
 
interface ElectronAPI {
  readFile: (path: string) => Promise<string>;
  writeFile: (path: string, data: string) => Promise<boolean>;
  selectDirectory: () => Promise<string | null>;
  getPlatform: () => string;
  getVersion: () => Promise<string>;
}
 
export function useElectron() {
  const [isElectron, setIsElectron] = useState(false);
 
  useEffect(() => {
    setIsElectron(typeof window !== "undefined" && "electronAPI" in window);
  }, []);
 
  const api = isElectron
    ? (window as any).electronAPI as ElectronAPI
    : null;
 
  return { isElectron, api };
}
// app/page.tsx
"use client";
 
import { useElectron } from "@/hooks/useElectron";
 
export default function Home() {
  const { isElectron, api } = useElectron();
 
  const handleSelectFolder = async () => {
    if (!api) return;
    const dir = await api.selectDirectory();
    if (dir) {
      console.log("Selected:", dir);
    }
  };
 
  return (
    <main>
      <h1>My Desktop App</h1>
      {isElectron ? (
        <button onClick={handleSelectFolder}>Select Folder</button>
      ) : (
        <p>Running in browser mode</p>
      )}
    </main>
  );
}

Step 6: Package Scripts

{
  "scripts": {
    "dev": "concurrently \"next dev\" \"wait-on http://localhost:3000 && electron .\"",
    "build": "next build && next export && electron-builder",
    "dist:win": "electron-builder --win",
    "dist:mac": "electron-builder --mac",
    "dist:linux": "electron-builder --linux"
  },
  "build": {
    "appId": "com.yourapp.desktop",
    "productName": "My Desktop App",
    "files": ["electron/**/*", "out/**/*"],
    "directories": { "output": "dist" },
    "win": { "target": "nsis" },
    "mac": { "target": "dmg" },
    "linux": { "target": "AppImage" }
  }
}

Security Best Practices

PracticeWhy
contextIsolation: truePrevents renderer from accessing Node.js
nodeIntegration: falseNo direct Node.js in browser context
Validate IPC inputsPrevent path traversal in file operations
Use preload scriptsControlled bridge between main and renderer

Key Takeaways

  • Keep the main process lean — UI logic belongs in the renderer
  • Use IPC for all communication between main and renderer processes
  • Context isolation + preload scripts = secure architecture
  • The app works as a web app in the browser and as a desktop app in Electron
  • Use electron-builder for cross-platform packaging and auto-updates