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
Component Version Node.js 18+ Electron 28+ Next.js 14+
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
Practice Why contextIsolation: truePrevents renderer from accessing Node.js nodeIntegration: falseNo direct Node.js in browser context Validate IPC inputs Prevent path traversal in file operations Use preload scripts Controlled 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