Skip to main content

Build a Desktop App with Capacitor and Electron (2026)

Turn your Capacitor app into a desktop app with Electron: secure shell setup, plugin strategy, packaging for macOS, Windows, Linux, and live updates.

Article credits

Martin Donadieu

Writer

Valeria

Reviewer

Jordan

Editor

Build a Desktop App with Capacitor and Electron (2026)

To build a desktop app from a Capacitor app, wrap the same web build (webDir) in an Electron shell: a main process that opens a BrowserWindow, a preload script that exposes a small desktop API, and electron-builder to produce installers for macOS, Windows, and Linux. Your UI and business logic stay shared with iOS and Android, and desktop-only features live in the Electron main process.

This guide covers the current state of Capacitor on Electron in 2026, a secure setup that works with Capacitor 8, how plugins behave, packaging and signing, and how to ship web updates without new installers.

The state of Capacitor on Electron in 2026

For years the default answer was @capacitor-community/electron, which plugged Electron into the Capacitor CLI as a platform. That project is now marked unmaintained in its own README. Its last npm release, 5.0.1, shipped in September 2023, requires Capacitor 5.4 or later, and was built against Electron 26. Electron has shipped many major versions since, and only the latest three majors receive security fixes.

You can still use it on old projects, but for a Capacitor 8 app the comparison looks like this:

Approach Pros Cons
Your own Electron shell (this guide) Current Electron, full control, no extra abstraction You write about 60 lines of main and preload code
@capacitor-community/electron Familiar cap commands Unmaintained, pinned to old Capacitor and Electron

The shell approach is less work than it sounds, because Capacitor already produces a static web build. Electron only needs to load it.

How plugins behave in Electron

Electron’s renderer is Chromium, so your Capacitor app runs there like it runs in a browser. Capacitor’s core detects no native bridge and falls back to each plugin’s web implementation.

Plugin type Works in Electron? What to do
Plugin with a web implementation (Preferences, Share, Clipboard, many Capgo plugins) Yes, using the web version Test the web behavior
Native-only plugin (no web implementation) No, rejects as unimplemented Write an Electron equivalent behind a preload API
Pure JS libraries Yes Nothing

Capacitor.getPlatform() returns 'web' in this setup and Capacitor.isNativePlatform() returns false. We will expose an explicit desktop flag from the preload script so your code can branch cleanly.

Prerequisites

  • A Capacitor app whose bun run build outputs a static folder (the webDir in capacitor.config.ts, often dist or www).
  • Bun and a current LTS Node.js, since Electron tooling still runs on Node.
  • For macOS distribution, an Apple Developer ID certificate. For Windows, a code signing certificate.

Step 1: Install Electron and the packager

bun add -d electron electron-builder

Add an electron/ folder at the project root:

my-app/
  capacitor.config.ts
  dist/              # web build, shared with iOS and Android
  electron/
    main.ts
    preload.ts
  electron-builder.yml

Step 2: Make the web build load from disk

Electron loads files with the file:// protocol in production. Two settings matter:

  1. Relative asset paths. With Vite, set base: './' so the built index.html references ./assets/... instead of /assets/....
  2. Routing. Deep links like /settings do not exist as files. Use hash routing for the desktop build, or make sure your router only navigates client-side after loading index.html.
// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  base: './',
});

Relative paths also work on iOS and Android, so you do not need a separate build.

Step 3: Write the main process

// electron/main.ts
import { app, BrowserWindow, ipcMain, shell } from 'electron';
import path from 'node:path';

const isDev = !app.isPackaged;

async function createWindow() {
  const win = new BrowserWindow({
    width: 1280,
    height: 800,
    minWidth: 800,
    minHeight: 600,
    show: false,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: true,
    },
  });

  win.once('ready-to-show', () => win.show());

  // Open external links in the default browser, never inside the app.
  // Only allow https: so a script cannot launch file: or other protocol handlers.
  win.webContents.setWindowOpenHandler(({ url }) => {
    try {
      if (new URL(url).protocol === 'https:') void shell.openExternal(url).catch(() => {});
    } catch {
      // invalid URL: ignore
    }
    return { action: 'deny' };
  });

  if (isDev) {
    await win.loadURL('http://localhost:5173');
    win.webContents.openDevTools({ mode: 'detach' });
  } else {
    await win.loadFile(path.join(__dirname, '..', '..', 'dist', 'index.html'));
  }
}

ipcMain.handle('desktop:get-version', () => app.getVersion());

app.whenReady().then(createWindow);

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});

app.on('activate', () => {
  if (BrowserWindow.getAllWindows().length === 0) createWindow();
});

Keep contextIsolation: true, nodeIntegration: false, and sandbox: true. Your web app may load third-party scripts, and none of them should get Node.js access.

Step 4: Expose a small desktop API from the preload

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

contextBridge.exposeInMainWorld('desktop', {
  isElectron: true,
  platform: process.platform,
  getVersion: () => ipcRenderer.invoke('desktop:get-version') as Promise<string>,
});

Type it in your web code and use it the same way you would use a plugin:

// src/desktop.ts
declare global {
  interface Window {
    desktop?: {
      isElectron: boolean;
      platform: string;
      getVersion: () => Promise<string>;
    };
  }
}

export const isDesktop = () => Boolean(window.desktop?.isElectron);

Each native-only feature you need on desktop becomes one ipcMain.handle in the main process and one function in the preload. Validate arguments in the main process, since the renderer is untrusted.

Step 5: Build and run

Compile the Electron files with Bun. The preload runs sandboxed, so bundle it as a single CommonJS file:

{
  "main": "electron/dist/main.js",
  "scripts": {
    "build": "vite build",
    "electron:compile": "bun build electron/main.ts --outdir electron/dist --target node --format cjs --external electron && bun build electron/preload.ts --outdir electron/dist --target node --format cjs --external electron",
    "electron:dev": "bun run electron:compile && electron .",
    "electron:pack": "bun run build && bun run electron:compile && electron-builder"
  }
}

During development, start your dev server in one terminal (bun run dev) and bun run electron:dev in another. You get hot reload in the desktop window.

Step 6: Package installers with electron-builder

# electron-builder.yml
appId: com.example.myapp
productName: My App
directories:
  output: release
files:
  - dist/**/*
  - electron/dist/**/*
  - package.json
mac:
  target: [dmg, zip]
  category: public.app-category.productivity
  hardenedRuntime: true
win:
  target: [nsis]
linux:
  target: [AppImage, deb]
  category: Utility

Run bun run electron:pack. electron-builder builds for the OS you run it on. Use a matrix of macOS, Windows, and Linux runners in CI to produce all three.

OS Common targets Signing
macOS .dmg, .zip Developer ID certificate plus notarization, or Gatekeeper blocks the app
Windows NSIS .exe, MSI Code signing certificate to avoid SmartScreen warnings
Linux AppImage, .deb, .rpm Optional

Electron apps are larger than mobile builds because they include Chromium and Node.js. Expect installers around 80 to 150 MB depending on targets.

Step 7: Ship web updates without new installers

Most changes in a Capacitor app are web changes. On mobile you can ship those with Capgo live updates. On desktop, @capgo/electron-updater does the same job with the same model: channels, a built-in bundle, downloaded bundles, and automatic rollback if the new bundle does not call notifyAppReady().

bun add @capgo/electron-updater

Update the main process to let the updater pick the bundle to load:

// electron/main.ts (production branch)
import { ElectronUpdater, setupIPCHandlers, setupEventForwarding } from '@capgo/electron-updater';

const updater = new ElectronUpdater({
  appId: 'com.example.myapp',
  autoUpdate: true,
});

// inside createWindow(), instead of loading dist/index.html directly
const builtinPath = path.join(__dirname, '..', '..', 'dist', 'index.html');
await updater.initialize(win, builtinPath);
setupIPCHandlers(updater);
setupEventForwarding(updater, win);
await win.loadFile(updater.getCurrentBundlePath());

Add the updater bridge to the same preload file:

// electron/preload.ts (next to the desktop API)
import { exposeUpdaterAPI } from '@capgo/electron-updater/preload';

exposeUpdaterAPI();

And confirm a healthy launch from the web app, only on desktop:

import { isDesktop } from './desktop';

if (isDesktop()) {
  const { requireUpdater } = await import('@capgo/electron-updater/renderer');
  await requireUpdater().notifyAppReady();
}

Upload a new bundle with the same CLI you use for mobile:

bun run build
bunx @capgo/cli@latest bundle upload --channel=production

Web updates cannot change Electron itself, the main process, or the preload. For those, ship a new installer with a binary auto-updater. Our guide on Electron auto-updates covers that path, and the electron-updater plugin page and docs cover every option.

Troubleshooting

Blank window in the packaged app. Assets use absolute paths. Set base: './' and rebuild. Open DevTools in the packaged app to confirm 404s on file:///assets/....

Routes show a blank page after reload. The router tried to load /route from disk. Switch to hash routing for desktop.

require is not defined or preload errors. The sandboxed preload cannot load arbitrary packages at runtime. Bundle it into one file, as in the compile script above.

A plugin call rejects with “not implemented on web”. That plugin has no web implementation. Add an IPC handler in the main process and call it through window.desktop.

macOS says the app is damaged or from an unidentified developer. The app is not signed and notarized. Configure a Developer ID certificate and notarization in electron-builder.

Updates roll back right after install. notifyAppReady() was not called within the timeout (10 seconds by default). Call it early in your app startup.

Wrap-up

You do not need a dedicated Capacitor platform package to reach the desktop. A short Electron main process, a preload that exposes a typed API, relative asset paths, and electron-builder give you installers for all three operating systems from the same web build. Add @capgo/electron-updater and the desktop app gets the same live update workflow as your iOS and Android builds. If you are still choosing a desktop runtime, read our Electron vs Tauri comparison.

Live updates for Capacitor apps

When a web-layer bug is live, ship the fix through Capgo instead of waiting days for app store approval. Users get the update in the background while native changes stay in the normal review path.

human support from Martin

Get Started Now

Latest from our Blog

Capgo gives you the best insights you need to create a truly professional mobile app.