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.
Install in BlintOpens 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:
- Main process — CommonJS output, SSR mode, sourcemaps on.
- Preload script — CommonJS output, minification off.
- 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)— wrapsipcRenderer.invokeon(channel, callback)— wrapsipcRenderer.onand 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_CHANNELSconstant object, named"domain:action"("pattern:list","session:start"). Both processes import it. - One file per domain under
src/main/ipc/, each exporting aregister*Ipc()function that binds handlers withipcMain.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
BrowserWindowfrom 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
invokeinuseQuery/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 parseslocation.hashto 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.