← Back to registry

Electron App Architecture

The repeatable skeleton for a multi-window Electron + Vite + React app — three builds, a locked-down preload bridge, domain-scoped IPC, and the boot-time pitfalls that produce blank windows.

Torben Sko @torben v2 architecture:repo-structurearchitecture:code-organisation
Install in Blint

Opens the Blint desktop app and adds this pattern to your project. Don't have Blint?


Electron App Architecture

The structure below has proven repeatable across multiple Electron apps. Every rule exists because its absence produced a real failure — the blank-window pitfalls at the end are the expensive ones.

Three-Build Vite Configuration

Use Electron Forge with the Vite plugin, producing three separate builds, each with its own vite.*.config.mjs:

  1. Main process — CommonJS output, SSR mode, sourcemaps on.
  2. Preload script — CommonJS output, minification off.
  3. Renderer — standard Vite React build (ESM), entry index.html.

Main and preload target Node; the renderer targets the browser. All three share one TypeScript path alias (@/*src/*). In development the renderer runs on the Vite dev server and the main process polls for its readiness before loading the URL; in production the main process loads built HTML from disk. Never branch on environment beyond that one dev/prod URL decision.

Security: Context Isolation and the Preload Bridge

Expose exactly one object to the renderer — window.electronAPI via contextBridge.exposeInMainWorld — carrying:

  • invoke(channel, ...args) — wraps ipcRenderer.invoke
  • on(channel, callback) — wraps ipcRenderer.on and returns a cleanup function
  • small conveniences (e.g. getVersion())

No raw Electron APIs reach the renderer. Node integration off, context isolation on, sandbox on. If a renderer needs a new capability, it gets a new IPC channel, never a wider bridge.

IPC Conventions

  • One source of truth for channel names. All channels live in a single IPC_CHANNELS constant object, named "domain:action" ("pattern:list", "session:start"). Both processes import it.
  • One file per domain under src/main/ipc/, each exporting a register*Ipc() function that binds handlers with ipcMain.handle(). The entry point calls every registration function on app-ready.
  • Handlers are thin. Extract the window context, call a service method, return the result. Business logic lives in services, never in handlers.
  • Scope services by window. A helper resolves the BrowserWindow from the IPC event and returns service instances scoped to that window's project/context, so windows on different projects never cross-contaminate.
  • Renderer consumption goes through React Query. Hooks wrap invoke in useQuery/useMutation; query keys are scoped by project path to prevent cache collisions across windows.
  • Main → renderer pushes use webContents.send(); the renderer subscribes through the bridge and responds by invalidating the relevant query keys — push events carry "something changed", not payloads to merge by hand.

Windows

Run a WindowManager service in the main process that tracks every window by ID with its project path and context (spec ID, chat ID, …):

  • Multi-window, multi-project — simultaneous windows on different projects.
  • Window reuse — before opening a window for a context, focus the existing one if it exists.
  • Bounds persistence — save on quit, restore on launch.
  • Hash routes name windows. Every window loads the same renderer with a hash route (#/session/:id, #/settings?section=…); the renderer parses location.hash to decide which window shell to mount. No URL router library is required for this — the hash is the window's identity, not navigation state.

Windows are frameless (frame: false, titleBarStyle: "hidden"); the renderer supplies a draggable region via a dedicated CSS class.

Renderer Structure

Wrap the root in, outermost first: QueryClientProvider, ThemeProvider (dark/light via a class on the document root), the app's context provider (current project, recents), and an ErrorBoundary. Gate the main UI behind project selection.

State has exactly three tiers:

  • Server state — React Query over IPC. This is most of the app.
  • Global UI state — React Context (project, theme).
  • Local UI state — component useState.

No additional state library. If something feels like it needs one, it is almost always server state that should be a query.

Shared Workspace Package

Types and pure utilities shared between main and renderer live in a dedicated workspace package (packages/shared/): domain models, mutation input types, enums, pure functions. IPC channel names and message shapes stay in the app — the main process defines handlers imperatively.

The dev-server alias pitfall (blank-window bug #1): the renderer's Vite config must alias the shared package to its source files, not resolve it through the node_modules symlink — and exclude it from optimizeDeps. Vite serves /node_modules/ URLs with immutable caching headers, and Electron's persistent disk cache keeps serving stale copies after the shared package changes: new exports "don't exist", the module graph fails to instantiate, and the window renders blank. Source-path URLs are never cached immutably and are watched, so shared edits also hot-reload.

Boot Watchdog (blank-window bug #2)

A module-graph failure — bad import binding, stale cached module, syntax error — aborts the renderer entry before React mounts, leaving a silently blank window. Put a small inline watchdog in index.html: collect window.onerror messages, and shortly after load, if #root is still empty, paint the captured errors into the page. The failure is then visible even with DevTools closed. Keep native menu roles (View → Toggle Developer Tools) registered even in production builds so a broken renderer can always be inspected.

Main Process Services

Business logic lives in service classes under src/main/services/, one per concern, instantiated lazily per project/window context — never eagerly at app startup. Startup should do almost nothing beyond registering IPC and opening the first window.

Store

Persistent settings use electron-store behind a small async-initialised accessor, loaded lazily (dynamic import) so it never blocks startup. It holds window state, recent projects, preferences, and session mappings — not domain data.

Styling

Define design tokens (colours, radii, spacing) as CSS custom properties in one place, with dark mode as a class on the document root toggled by the theme provider. Scope component styles with CSS Modules against those tokens. Repeated utility combinations get a named class, not a longer class list. Avoid coupling the app to a utility framework — token CSS plus modules ports cleanly between apps; a framework's build integration does not.