lunx.docs
DocsFrameworksElectron

Electron

Desktop apps built by lunx itself: the renderer, the main process and the preload, with Electron started on the dev server. No plugin, nothing to configure.

Electron · built in
One dev command

lunx dev serves the renderer with HMR, builds main and preload with Rolldown, and starts Electron on the dev server.

Rebuilt on change

Editing the main process restarts Electron; editing the preload reloads the window. Quitting the app ends lunx dev.

Ready to package

dist/ (renderer, relative URLs) and dist-electron/ (main, preload); package.json main points at dist-electron/main.js.

contextBridge preload

The preload exposes a typed API to the renderer; nodeIntegration stays off.

Setup

bash
npx lunx-dev@latest create my-electron-app --template electron
cd my-electron-app && npm install
npm run dev       # renderer with HMR, Electron started on it
npm run build     # dist/ and dist-electron/
npm run preview   # the build, in Electron

Any project with electron installed and an electron/main.ts (or .js) gets the same, React or not. An existing project with vite-plugin-electron runs on lunx unchanged: lunx leaves Electron to the plugin.

Project structure

text
my-electron-app/
├── electron/
│   ├── main.ts       # Electron main process
│   └── preload.ts    # contextBridge preload
├── src/              # React renderer
│   ├── main.tsx
│   └── App.tsx
├── index.html
└── package.json      # "main": "dist-electron/main.js"

What lunx builds

  • Renderer → dist/, with relative URLs (base: './'), since Electron loads it from disk.
  • Main process → dist-electron/main.js, ESM or CommonJS like your package. Packages in dependencies stay in node_modules; everything else is bundled.
  • Preload → dist-electron/preload.cjs, one CommonJS file, as sandboxed preloads (Electron's default) require.

The main process and the preload build in parallel with the renderer. In dev, Electron gets the dev server's URL in LUNX_DEV_SERVER_URL (and VITE_DEV_SERVER_URL, for code written for vite-plugin-electron).

Configuration (optional)

typescriptlunx.config.ts
import { defineConfig } from 'lunx-dev'

export default defineConfig({
  electron: {
    main: 'src-electron/index.ts',      // default: electron/main.*
    preload: 'src-electron/preload.ts', // default: electron/preload.*; false for none
    outDir: 'dist-electron',            // default
  },
  // electron: false — lunx leaves Electron alone
})

LUNX_ELECTRON_ARGS="--inspect=5858" npm run dev passes switches to Electron (here, to debug the main process).

Main process

typescriptelectron/main.ts
import { app, BrowserWindow, ipcMain, dialog } from 'electron'
import path from 'node:path'
import fs from 'node:fs/promises'
import { fileURLToPath } from 'node:url'

const dir = path.dirname(fileURLToPath(import.meta.url))

function createWindow() {
  const win = new BrowserWindow({
    width: 1280,
    height: 800,
    webPreferences: { preload: path.join(dir, 'preload.cjs') },
  })
  // `lunx dev` serves the renderer; a build loads it from disk.
  if (process.env.LUNX_DEV_SERVER_URL) win.loadURL(process.env.LUNX_DEV_SERVER_URL)
  else win.loadFile(path.join(dir, '../dist/index.html'))
}

// Runs in the privileged main process; the renderer asks through the preload.
ipcMain.handle('dialog:openFile', async () => {
  const { filePaths } = await dialog.showOpenDialog({ properties: ['openFile'] })
  if (filePaths.length === 0) return null
  return { path: filePaths[0], content: await fs.readFile(filePaths[0], 'utf-8') }
})

app.whenReady().then(createWindow)

Preload — contextBridge

typescriptelectron/preload.ts
import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('electronAPI', {
  openFile: () => ipcRenderer.invoke('dialog:openFile'),
})

Using the API in the renderer

typescriptsrc/FileViewer.tsx
import { useState } from 'react'

// Types for the API the preload exposes
declare global {
  interface Window {
    electronAPI: {
      openFile:   () => Promise<{ path: string; content: string } | null>
    }
  }
}

export function FileViewer() {
  const [content, setContent] = useState('')
  const [filePath, setFilePath] = useState<string | null>(null)

  async function open() {
    const result = await window.electronAPI.openFile()
    if (result) {
      setContent(result.content)
      setFilePath(result.path)
    }
  }

  return (
    <div>
      <div>
        <button onClick={open}>Open File</button>
        {filePath && <span>{filePath}</span>}
      </div>
      <textarea value={content} onChange={e => setContent(e.target.value)} />
    </div>
  )
}

Packaging

npm run build leaves the app ready for electron-builder or Electron Forge: dist/ holds the renderer (with relative URLs, as it loads from disk) and dist-electron/ the main process and the preload.

Security: never enable nodeIntegration
Setting nodeIntegration: true in Electron gives every loaded web page direct access to Node.js APIs — a critical security vulnerability. Always use contextIsolation: true and the contextBridge preload pattern to expose only the specific APIs your renderer needs.