An opinionated starter for building macOS desktop apps with Electron, tuned for productive Claude Code sessions.
Electron + React 19 + TypeScript 6 + Vite 8 + Tailwind CSS v4 + Bun — wired up with strict lint, a no-useEffect policy, a secure typed-IPC preload, and a .claude/ directory full of slash commands and subagents that keep Claude aligned with the codebase conventions.
If you've ever started an Electron app and spent the first two days fighting tooling, this is the scaffold you wish you'd had.
Most Electron starters ship you the bones and leave the opinions to you. This one ships the opinions, because opinions are what make a codebase pleasant to work in with an AI pair programmer:
- Claude knows the rules.
CLAUDE.mdencodes the commands, planning guidelines, and non-negotiable conventions..claude/rules/holds path-scoped rules for React and Electron..claude/commands/provides/commit,/pr,/review-code,/simplify,/useeffect-janitor, and/comment-janitor— working slash commands adapted from real production setups. - The process boundary is real.
src/main,src/preload, andsrc/renderercan never import each other's runtime code. Shared types go insrc/shared/. The preload exposes a typedwindow.apiviacontextBridge, never rawipcRenderer. - Security defaults are non-negotiable.
contextIsolation: true,sandbox: true,nodeIntegration: false,webSecurity: true. The rules file tells Claude to flag (not silently change) anything that would weaken these. - Strict everything. TypeScript
strict+noUncheckedIndexedAccess+exactOptionalPropertyTypes. ESLint flat config withstrictTypeChecked+stylisticTypeChecked.--max-warnings=0. Noany, noas Xas a shortcut, noeslint-disablewithout a scoped reason. - React 19, the way React 19 wants to be written. Direct
useEffectis banned by ESLint.forwardRefis banned.useContextis banned (use theuse()hook). A sanctioneduseMountEffectwrapper is the one placeuseEffectis allowed, with narrowly-scoped disables that model how the rest of the codebase should handle exceptions. - Bun everywhere. Install, scripts, formatting, type-checking — all via
bun. Auto-loaded.env, faster installs, one binary.
| Layer | Choice | Why |
|---|---|---|
| App framework | Electron | The desktop runtime |
| Build tooling | electron-vite | Three-process (main/preload/renderer) Vite with HMR |
| UI | React 19 | Compiler-aware, use() hook, ref-as-prop |
| Language | TypeScript 6 (strict) | Project references split node vs. web |
| Styling | Tailwind CSS v4 | CSS-first via @import "tailwindcss" |
| Lint | ESLint 10 flat config + typescript-eslint | Strict type-checked rules, banned imports |
| Format | Prettier | with @ianvs/prettier-plugin-sort-imports + prettier-plugin-tailwindcss |
| Hooks | Lefthook | Pre-commit format, pre-push check-types + lint |
| Package manager | Bun | Install + script runner |
| Packaging | electron-builder | .dmg for macOS (signing/notarization TODO) |
# 1. Use this as a template
gh repo create my-app --template shri/claude-electron-starter-app --public --clone
cd my-app
# 2. Install
bun install
# 3. Launch the dev window
bun run devYou should see an Electron window titled "Claude Electron Starter" with a Tailwind-styled hello screen and a button that round-trips through IPC to the main process.
git clone https://github.com/shri/claude-electron-starter-app.git my-app
cd my-app
rm -rf .git && git init -b main
bun install
bun run dev| Task | Command | Notes |
|---|---|---|
| Dev | bun run dev |
electron-vite with HMR on main, preload, and renderer |
| Build | bun run build |
Type-checks then builds all three processes to out/ |
| Build .dmg | bun run build:mac |
Packages to release/ — unsigned by default |
| Type-check | bun run check-types |
Separate tsc --noEmit passes for node and web tsconfigs |
| Lint | bun run lint |
ESLint flat config, --max-warnings=0 |
| Lint (fix) | bun run lint:fix |
|
| Format | bun run format |
Prettier over the whole tree |
| Format check | bun run format:check |
CI-style, no writes |
Always bun, never npm. bunx, never npx.
.
├── .claude/ # Claude Code configuration
│ ├── settings.json # Permissions, hooks, statusline
│ ├── statusline.sh # Model + git + context + tokens statusline
│ ├── hooks/
│ │ ├── format-file.sh # Prettier on every Edit/Write
│ │ └── protect-files.sh # Blocks writes to .env, bun.lock, .git, out/
│ ├── rules/
│ │ ├── react.md # React 19 conventions (path-scoped to **/*.tsx)
│ │ └── electron.md # Security model + process boundary
│ ├── agents/
│ │ └── code-reviewer.md # opus subagent invoked by /review-code
│ └── commands/ # Slash commands — see below
├── .vscode/ # Format on save, ESLint, Tailwind IntelliSense
├── src/
│ ├── main/
│ │ └── index.ts # Electron main: window + IPC handlers
│ ├── preload/
│ │ └── index.ts # contextBridge surface (typed Api export)
│ └── renderer/
│ ├── index.html
│ └── src/
│ ├── main.tsx
│ ├── app.tsx
│ ├── env.d.ts # Ambient Window.api type + vite/client
│ ├── hooks/
│ │ └── use-mount-effect.ts # sanctioned useEffect wrapper
│ ├── lib/
│ │ └── cn.ts # conditional classnames helper
│ └── styles/
│ └── globals.css # Tailwind v4 entry
├── CLAUDE.md # Claude Code operational doc
├── electron.vite.config.ts
├── electron-builder.yml
├── eslint.config.js
├── tsconfig.json # Project references
├── tsconfig.node.json # main + preload + config files
└── tsconfig.web.json # renderer
The @ alias resolves to src/renderer/src/* in the renderer — set in both tsconfig.web.json and electron.vite.config.ts.
Drop into the repo, start Claude Code, and the .claude/ directory is picked up automatically. The things you'll use most:
| Command | What it does |
|---|---|
/commit |
Stages + analyzes the diff and writes a conventional commit message (emoji + type + summary). Asks before guessing on ambiguous changes. |
/pr |
Runs quality gates (check-types, lint), pushes the branch, and opens a GitHub PR with a synthesized title and description. Accepts draft as an argument. |
/review-code |
Delegates to the code-reviewer subagent (runs on Opus). Reports only issues worth fixing, grouped by severity. Works on uncommitted changes, a specific commit, or a file. |
/simplify |
Reduces complexity in the target files using explicit simplification principles (early returns, small functions, no nested ternaries, delete dead code). Re-runs type-check + lint after. |
/useeffect-janitor |
Refactors common useEffect anti-patterns (derived state, reset-on-prop, event-driven logic, chains of effects, data fetching, notify-parent) into their React 19 equivalents. Leaves legitimate effects alone. |
/comment-janitor |
Two-pass: removes noisy comments that restate code, adds helpful ones for non-obvious business logic. |
code-reviewer(opus) — invoked by/review-code. Reviews for correctness, dead code, readability, type safety, performance, and convention violations. Skips praise and nitpicks.
You can chain these at the end of a task: /useeffect-janitor → /review-code → /comment-janitor → bun run check-types && bun run lint → /commit → /pr.
Two .claude/hooks/ scripts run automatically:
format-file.sh— on everyEdit/Write, runsprettier --writeon the touched file so formatting never drifts from the saved state.protect-files.sh— on everyEdit/Write, blocks writes to.env*,bun.lock,.git/,out/,dist/,release/, andnode_modules/. Edit thePROTECTED_PATTERNSarray to add your own.
.claude/statusline.sh shows the current model, directory, git branch + diff summary, context window usage (with a colored progress bar), token count, and session duration in the Claude Code status line. No config needed — it's just wired up.
.claude/settings.json sets defaultMode: "plan" — Claude will draft a plan and show it for approval before making changes on any non-trivial task. Override per-session if plan mode is too chatty for your flow.
These are encoded in CLAUDE.md, .claude/rules/react.md, .claude/rules/electron.md, and enforced by ESLint where possible.
-
useEffectis banned. Direct imports ofuseEffectfromreactare a lint error. Alternatives live in.claude/rules/react.md:- Mount/cleanup →
useMountEffect(the one sanctioned wrapper) - Derived state → compute during render
- Event-driven logic → put it in the handler
- Reset on prop change → use the
keyprop - Data fetching → use a data library (TanStack Query, SWR, etc.)
- Notify parent of state change → call the callback in the same handler
Running
/useeffect-janitorrefactors common anti-patterns automatically. - Mount/cleanup →
-
No
forwardRef. React 19 supportsrefas a regular prop. -
No
useContext. Use theuse()hook — it can be called conditionally. -
No speculative
useMemo/useCallback. The React Compiler handles memoization. Only use them when an external dependency needs a stable identity.
- Never disable
contextIsolation,sandbox,webSecurity, or enablenodeIntegration. - Never expose raw
ipcRendererto the renderer. UsecontextBridge.exposeInMainWorld('api', {...})insrc/preload/index.tswith a typed surface. - The renderer never imports
fs,child_process,path,os. If it needs one, add an IPC method in main and validate the input at the boundary. src/mainandsrc/renderercan't import each other's code. Shared types go insrc/shared/(type-only, no runtime).
- Avoid type assertions.
as Xis a code smell — if you're reaching for it, consider whether the types are modeled wrong. - Never
as anyorunknown as X. Hard rule. - Don't disable ESLint/TypeScript rules without explicit consent. If you must, scope the disable as tightly as possible and explain why. The codebase has exactly one place that does this:
src/renderer/src/hooks/use-mount-effect.ts, which is the sanctioneduseEffectwrapper. That's the template for how to write a principled exception.
kebab-casefile names (enforced byeslint-plugin-check-file)Array<T>notT[](enforced by@typescript-eslint/array-type)- No barrel files (
index.tsre-exports) - No nested ternaries
- Top-to-bottom readability: helpers go below their consumers
- Conditional classnames via
cn()from@/lib/cn, never template literals - Sorted imports (automatic via
@ianvs/prettier-plugin-sort-imports): builtins → third-party →@/*→ relative, blank line between groups
The pattern is end-to-end typed. To add a readFile(path) method:
-
Main (
src/main/index.ts):ipcMain.handle('read-file', async (_event, filePath: string): Promise<string> => { // Validate — untrusted input. Guard against path traversal, etc. return await readFile(filePath, 'utf8'); });
-
Preload (
src/preload/index.ts):const api = { ping: (): Promise<string> => ipcRenderer.invoke('ping'), readFile: (path: string): Promise<string> => ipcRenderer.invoke('read-file', path), } as const;
-
Renderer ambient type (
src/renderer/src/env.d.ts):interface StemMagicApi { ping: () => Promise<string>; readFile: (path: string) => Promise<string>; }
-
Renderer anywhere:
const contents = await window.api.readFile('/path/to/file');
For a production app, wrap step 1 with a Zod schema (or similar) to validate the argument before it reaches the filesystem.
bun run build:mac produces an unsigned .dmg in release/. To ship a distributable build you still need to:
- Get an Apple Developer ID Application certificate
- Set
APPLE_ID,APPLE_APP_SPECIFIC_PASSWORD,APPLE_TEAM_IDas environment variables - Add an
afterSignhook toelectron-builder.ymlthat calls@electron/notarize - Update
appIdandproductNameto match your app
This starter intentionally doesn't wire up signing — every project's signing story is different, and getting it wrong corrupts builds in subtle ways.
A few things you'll probably want to change first:
- App identity:
package.json(name,description),electron-builder.yml(appId,productName,category),src/main/index.ts(setAppUserModelId),src/renderer/index.html(title),src/renderer/src/app.tsx(hero text). - Window:
src/main/index.ts— size, title bar style, background color, min constraints. - Protected files:
.claude/hooks/protect-files.sh— add paths Claude shouldn't overwrite. - Permissions:
.claude/settings.jsonpermissions.allow— add any project-specific commands you want pre-authorized. - Default mode:
.claude/settings.json— changedefaultModefrom"plan"to"acceptEdits"or"default"if plan mode is too chatty for your flow.
MIT. Use it, fork it, rip the .claude/ directory into your own scaffold — no credit required but appreciated.
The .claude/ setup is adapted from the production Claude Code configuration of the Gobi codebase — particularly the slash commands, the code-reviewer subagent, the React 19 rules, and the format-on-write/protect-files hook pattern. Full credit to that team for figuring out what actually works.