Most Electron apps start life as a web SPA that someone wrapped in a window. That works for a demo, but the renderer in a desktop app has different constraints: it loads from the filesystem or a local dev server, it talks to a privileged main process through a narrow bridge, it must survive CSP and sandbox rules, and it ships as a frozen bundle inside an installer rather than a CDN. Integrating React or Vue well means treating the framework as the renderer layer of a three-process architecture, not as a website that happens to run on the desktop. This guide covers the project layout, the typed preload bridge, dev-mode hot reload, production packaging, where state and IPC belong, and the pitfalls that only appear after you ship an installer.
TL;DR
- Keep the three layers separate: main owns privileges and Node access, preload exposes a narrow typed bridge, the framework renderer owns UI and state. Never let the renderer reach Node directly.
- Use a bundler that knows Electron: Vite (or electron-vite) builds main, preload, and renderer as separate targets; the renderer bundle is what ships in the asar.
-
Expose a minimal
contextBridgeAPI with explicit method names and payload types. The framework callswindow.api.*, neverrequireoripcRenderer. -
In dev, load the Vite dev server so HMR works; in production, load the built
index.htmlfrom disk. The main process switches between the two by an environment flag. - Put durable state in main or a local store, keep framework state ephemeral; cross-window sync goes through main events, not shared module singletons.
-
The two traps that cost me days: (1) enabling
nodeIntegrationor disablingcontextIsolationto make a library "just work" — it erases the security boundary; (2) routing and assets configured for a web base path, which breaks when the app loads overfile://or a custom protocol.
Table of contents
- Why a framework in Electron differs from a web SPA
- The three-layer architecture
- Project layout and build setup
- The preload bridge: a typed, minimal API
- Wiring React or Vue to the bridge
- Dev mode: hot reload against the Electron renderer
- Production build and packaging the renderer bundle
- State management and where IPC belongs
- Routing, deep links, and multi-window state
- Common pitfalls
- Checklist
Why a framework in Electron differs from a web SPA
A browser SPA assumes a public origin, a network, and a reload button. An Electron renderer assumes none of those by default:
- It runs next to a privileged process. The renderer can request file access, shell commands, and system dialogs through main. That power has to flow through a controlled channel, not a direct Node handle.
-
It loads locally. In production the page comes from
file://, a custom protocol, or a bundled dev server — not a web origin. Asset paths, router base URLs, and absolute URLs that work on the web often break here. -
It is sandboxed by default. Modern Electron recommendations run the renderer with
contextIsolation: trueandsandbox: true, which removes the Node globals the web version of your code expects. - It ships frozen. The bundle inside the installer is fixed until the next release; there is no CDN cache busting and no "reload to get the new JS" assumption.
The framework itself does not care whether it mounts in Chromium on the web or in an Electron window. The surrounding integration does.
The three-layer architecture
| Layer | Process | Has Node access? | Owns | Talks to |
|---|---|---|---|---|
| Main | Main process | Yes | Windows, menus, dialogs, file system, network, stores | Renderers via IPC |
| Preload | Per renderer, sandboxed | Limited, whitelisted | The typed bridge between main and renderer |
ipcRenderer to main, contextBridge to window |
| Renderer | Renderer process | No | React/Vue UI, ephemeral state, user interaction |
window.api.* exposed by preload |
The rule that keeps this safe: the framework code imports components and hooks, and calls bridge methods. It never imports electron, node:fs, or ipcRenderer. If a feature needs a Node capability, you add a method to the bridge and a matching handler in main.
Project layout and build setup
A clean layout separates the three targets so the bundler can compile each with the right settings:
src/
main/
index.ts # app lifecycle, BrowserWindow, IPC handlers
preload/
bridge.ts # contextBridge exposure, channel names, types
renderer/
main.tsx # React/Vue entry, mounts the framework
App.tsx
api/
client.ts # typed wrapper over window.api
shared/
types.ts # payload types shared by main and renderer
With Vite (or electron-vite), each target has its own build config. The renderer is a normal web bundle; main and preload are Node/ESM targets that get bundled separately.
// vite.renderer.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; // or @vitejs/plugin-vue
export default defineConfig({
plugins: [react()], // replace with vue() for a Vue renderer
base: "./", // relative asset paths so the bundle loads over file:// or a custom protocol
build: {
outDir: "dist/renderer",
emptyOutDir: true,
},
server: {
port: 5173,
strictPort: true,
},
});
The base: "./" detail matters: an absolute /assets/... path resolves against the install root in production and breaks the moment the app is loaded from a non-root protocol.
The preload bridge: a typed, minimal API
The preload script runs before the renderer page and exposes a small, explicit surface on window. Keep channel names and payload types in a shared file so main and renderer agree on the contract.
// src/shared/types.ts
export interface Note {
id: string;
title: string;
body: string;
updatedAt: number;
}
export interface NotesApi {
list(): Promise<Note[]>;
save(note: Note): Promise<Note>;
remove(id: string): Promise<void>;
onChanged(cb: (notes: Note[]) => void): () => void;
}
// src/preload/bridge.ts
import { contextBridge, ipcRenderer } from "electron";
import type { NotesApi, Note } from "../shared/types";
const api: NotesApi = {
list: () => ipcRenderer.invoke("notes:list"),
save: (note: Note) => ipcRenderer.invoke("notes:save", note),
remove: (id: string) => ipcRenderer.invoke("notes:remove", id),
onChanged(cb) {
const listener = (_event: unknown, notes: Note[]) => cb(notes);
ipcRenderer.on("notes:changed", listener);
return () => ipcRenderer.removeListener("notes:changed", listener);
},
};
contextBridge.exposeInMainWorld("api", api);
// src/main/notes-handlers.ts
import { ipcMain } from "electron";
import type { Note } from "../shared/types";
export function registerNotesHandlers(getStore: () => {
list(): Note[];
save(note: Note): Note;
remove(id: string): void;
broadcast(notes: Note[]): void;
}) {
const store = getStore();
ipcMain.handle("notes:list", () => store.list());
ipcMain.handle("notes:save", (_e, note: Note) => store.save(note));
ipcMain.handle("notes:remove", (_e, id: string) => store.remove(id));
// broadcast pushes changes to every renderer; main owns the fan-out
}
Main validates every incoming payload before touching the store. The bridge is a contract, not a pass-through: add schema/shape checks in the handler, and never trust a renderer that could be compromised.
For TypeScript, declare the global once so the renderer sees window.api without casting everywhere:
// src/renderer/global.d.ts
import type { NotesApi } from "../shared/types";
declare global {
interface Window {
api: NotesApi;
}
}
export {};
Wiring React or Vue to the bridge
The framework talks to window.api through a thin client, usually wrapped in a hook or composable so components do not call IPC channels directly.
React example:
// src/renderer/hooks/useNotes.ts
import { useEffect, useState } from "react";
import type { Note } from "../../shared/types";
export function useNotes() {
const [notes, setNotes] = useState<Note[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let off: (() => void) | undefined;
window.api.list().then(setNotes).finally(() => setLoading(false));
off = window.api.onChanged(setNotes);
return () => off?.();
}, []);
return {
notes,
loading,
save: (note: Note) => window.api.save(note),
remove: (id: string) => window.api.remove(id),
};
}
Vue example (composable):
// src/renderer/composables/useNotes.ts
import { ref, onMounted, onUnmounted } from "vue";
import type { Note } from "../../shared/types";
export function useNotes() {
const notes = ref<Note[]>([]);
const loading = ref(true);
let off: (() => void) | undefined;
onMounted(async () => {
notes.value = await window.api.list();
loading.value = false;
off = window.api.onChanged((n) => (notes.value = n));
});
onUnmounted(() => off?.());
return {
notes,
loading,
save: (note: Note) => window.api.save(note),
remove: (id: string) => window.api.remove(id),
};
}
Components render from framework state; mutations go through the bridge; main pushes notes:changed back and the state refreshes through the subscription. This keeps the data flow one-directional and easy to reason about.
Dev mode: hot reload against the Electron renderer
In development you want Vite's HMR, but Electron needs to load the dev server URL rather than a built file. Main decides which to load based on an environment flag:
// src/main/index.ts
import { app, BrowserWindow } from "electron";
import path from "node:path";
const isDev = !app.isPackaged;
const RENDERER_DEV_URL = "http://localhost:5173";
function createWindow() {
const win = new BrowserWindow({
width: 1100,
height: 720,
webPreferences: {
preload: path.join(__dirname, "../preload/bridge.js"),
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
},
});
if (isDev) {
win.loadURL(RENDERER_DEV_URL);
win.webContents.openDevTools();
} else {
win.loadFile(path.join(__dirname, "../renderer/index.html"));
}
}
app.whenReady().then(createWindow);
Two processes run in dev: the Vite dev server serves the renderer with HMR, and Electron launches against it. When you change a component, Vite pushes the update and the window reloads the module without restarting the app. Main and preload changes still require an Electron restart; renderer changes hot-reload.
CSP in dev can be looser to allow the localhost HMR socket, but production CSP must tighten script-src and connect-src to bundled origins.
Production build and packaging the renderer bundle
The production flow builds all three targets, then the packager reads the output:
- Bundle the renderer to static HTML/JS/CSS in
dist/renderer. - Bundle main and preload to Node-compatible output.
- Point the packager (electron-builder or similar) at the built entry and let it copy the renderer into the asar.
Common asset gotchas:
- Import images and fonts through the bundler so they are hashed and emitted next to the JS; do not reference raw paths in
public/that assume a web root. - Set the renderer router's base to match the asset base; otherwise client-side routes resolve against the install path.
- Verify the built
index.htmlloads overfile://or your custom protocol in a packaged build, not only in the dev server.
A useful pre-package check: build once, launch the packaged output on a clean machine, and confirm the first paint does not depend on the dev server or any remote CDN.
State management and where IPC belongs
| Concern | Lives where | Why |
|---|---|---|
| UI state, form drafts, toggles | Framework store (React context/Zustand, Vue ref/Pinia) | Ephemeral, rebuilt on reload, fast to read |
| Derived/computed values | Framework selector/computed | Pure function of current state |
| User records, settings, offline queue | Main process or local store | Durable, shared across windows, survives reload |
| Side effects: file IO, shell, network, dialogs | Main, via bridge method | Needs privileges the renderer must not hold |
| Cross-window sync | Main broadcast events | Renderers cannot share module singletons |
Resist the urge to put a durable database directly in the renderer. Even where IndexedDB works, a renderer store is per-origin and per-window; the source of truth that multiple windows read and write belongs in main.
Routing, deep links, and multi-window state
Client-side routers work in Electron, but they need a base that matches how the page is loaded. Hash-based routing is the safest default for file://-loaded apps; history routing works cleanly when you register a custom protocol or control the dev URL.
For multiple windows, each renderer is a separate process with its own framework instance. Do not assume module state is shared: open a second window and it remounts fresh. To keep windows consistent, main holds the source state and broadcasts change events; every renderer subscribes and refreshes. Deep links (myapp://notes/123) are handled in main's protocol/second-instance handler, which tells the focused window which record to show.
Common pitfalls
| Pitfall | What happens | Fix |
|---|---|---|
nodeIntegration: true to use a library |
Renderer can reach Node; one XSS becomes full host compromise | Keep it off; expose only the needed bridge methods |
Disabling contextIsolation
|
Preload globals leak into the renderer's prototype chain | Keep isolation on; communicate only through contextBridge
|
| Absolute asset/base paths | Blank screen in packaged build, assets 404 over file://
|
Use relative base: "./" and bundle assets through Vite |
| Router base set for web hosting | Deep links refresh to a broken path | Match router base to the Electron load scheme; prefer hash routing |
| Durable state in renderer store | Second window diverges; reload loses unsynced records | Keep source of truth in main/local store; sync via events |
Calling ipcRenderer from framework code |
Couples UI to Electron; breaks the security boundary | Wrap everything in the window.api bridge |
| Dev CSP carried into production | Loose connect-src allows remote scripts; dev relaxations ship |
Build a production CSP with restricted script-src/connect-src
|
| Relying on remote CDN for first paint | App renders blank offline or on restricted networks | Bundle the framework and runtime locally; treat remote assets as optional |
Checklist
- [ ] Main, preload, and renderer are separate build targets with clear boundaries.
- [ ]
contextIsolation: true,nodeIntegration: false,sandbox: trueon everywebPreferences. - [ ] The renderer only calls
window.api.*; no directelectron/ipcRenderer/node:imports in framework code. - [ ] Bridge methods and payload types are defined in a shared contract file and validated in main handlers.
- [ ] Dev loads the Vite dev server with HMR; production loads the built bundle from disk.
- [ ] Renderer
baseand router base are relative or matched to the Electron load scheme. - [ ] Durable records live in main or a local store; cross-window changes broadcast through main.
- [ ] Production CSP restricts scripts and connections; dev-only relaxations are not shipped.
- [ ] Images/fonts are bundled through the build, not referenced by web-root paths.
- [ ] A clean packaged build renders the first paint without the dev server or a remote CDN.
Top comments (0)