A minimal, secure, and typed Electron template using Vite + React on the renderer, and tRPC over IPC for backend calls.
pnpm install
pnpm dev # start Vite and Electron (plugin handles main/preload)
pnpm build # build renderer and package Windows installerNote
If pnpm blocks Electron's postinstall on a fresh machine, run:
pnpm approve-builds.
Tip
Skim this to see what you get out of the box.
- React + Vite with instant HMR.
- React 19 with the React Compiler enabled.
- Hardened `BrowserWindow` defaults.
- Tiny preload that initializes the bridge via `exposeElectronTRPC()`.
- End‑to‑end types with zero HTTP server.
- Powered by `electron-trpc-experimental` (preload bridge + `ipcLink` + `createIPCHandler`).
- Supports queries, mutations, subscriptions, and async‑generator streaming.
- A per‑call context you can extend when needed.
- React Query for fetching and caching.
- TailwindCSS v4 for utility‑first styling.
- TanStack Router for file‑based routing.
- Import aliases `@web/*` and `@app/*` keep imports clean.
- React Icons included for convenient icons.
- `electron-builder.json5` is ready, and `pnpm build` creates a Windows installer.
- Auto‑update is supported with `electron-updater`.
Tip
Read more about how to ship updates for your app.
Note
Where things live.
electron/backend/functions/*holds Node‑only logic.electron/backend/routers/*exposes thin tRPC procedures.electron/backend/router.tscomposes domain routers intoappRouter.electron/backend/ipcTrpc.tsmounts the IPC handler viacreateIPCHandler.electron/backend/ctx.tsdefines the request context.electron/preload.tsinitializes the renderer bridge viaexposeElectronTRPC().electron/main.tscreates the window and registers the IPC handler before load.src/lib/trpcClient.tsconfigures the renderer client withipcLink().
This template uses electron-trpc-experimental for type‑safe IPC with tRPC.
It provides a clean separation across main, preload, and renderer with first‑class tRPC v11 support.
- Link: electron-trpc-experimental
- Why this package.
- tRPC v11 compatible and actively maintained.
- Clearly separated
main/preload/rendererentry points that match Electron’s security model. - Built‑in
ipcLinkfor the renderer andcreateIPCHandlerfor the main process. - Supports queries, mutations, subscriptions, and async‑generator streaming for live data.
- Examples and docs that mirror real Electron setups.
- Preload:
exposeElectronTRPCinitializes a minimal, safe bridge.
// electron/preload.ts
import { exposeElectronTRPC } from 'electron-trpc-experimental/preload';
process.once('loaded', () => {
exposeElectronTRPC();
});- Main:
createIPCHandlermounts the router and binds it to the window.
// electron/backend/ipcTrpc.ts
import { createIPCHandler } from 'electron-trpc-experimental/main';
import { appRouter } from '@app/backend/router';
createIPCHandler({ router: appRouter, windows: [win], createContext: () => ({}) });- Renderer:
ipcLinkconnects the tRPC client to the preload bridge.
// src/lib/trpcClient.ts
import { createTRPCProxyClient } from '@trpc/client';
import { ipcLink } from 'electron-trpc-experimental/renderer';
import type { AppRouter } from '@app/backend/router';
export const trpc = createTRPCProxyClient<AppRouter>({ links: [ipcLink()] });Note
This package eliminates manual ipcMain.handle routing and any custom window.trpc.invoke shim while keeping the surface area minimal and secure.
- Single source of truth: router types infer the client
- Secure: no HTTP server, no Node in renderer
- Minimal surface: package-provided bridge via
exposeElectronTRPC+ipcLink - Scales by domain: add functions → expose via routers → mount → use
Note
Read more about how to work with multiple windows.
Renderer → trpc client with ipcLink → preload bridge via exposeElectronTRPC → main createIPCHandler with appRouter → result → Renderer.
Example:
user.profile(read-only profile)
- Create backend function
- File:
electron/backend/functions/user.ts(or in a subfolder, if multiple files are needed)
export async function getProfile() {
return { id: 'u_1', name: 'Ada' };
}- Expose via a router
- File:
electron/backend/routers/user.ts
import { initTRPC } from '@trpc/server';
import type { CallerContext } from '@app/backend/ctx';
import { getProfile } from '@app/backend/functions/user';
const t = initTRPC.context<CallerContext>().create();
export const userRouter = t.router({
profile: t.procedure.query(() => getProfile()),
});- Mount the domain router
- File:
electron/backend/router.ts
import { initTRPC } from '@trpc/server';
import type { CallerContext } from '@app/backend/ctx';
import { osRouter } from '@app/backend/routers/os';
import { userRouter } from '@app/backend/routers/user';
const t = initTRPC.context<CallerContext>().create();
export const appRouter = t.router({
os: osRouter,
user: userRouter,
});
export type AppRouter = typeof appRouter;- Call it from the renderer
- In a component using React Query:
import { useQuery } from '@tanstack/react-query';
import { trpc } from '@web/lib/trpcClient';
const { data: profile } = useQuery({
queryKey: ['user.profile'],
queryFn: () => trpc.user.profile.query(),
staleTime: Infinity,
});Tip
Need input validation? Add ArkType:
import { type } from 'arktype';
const UpdateNameInput = type({ name: 'string>0' });
export const userRouter = t.router({
updateName: t.procedure.input((raw) => UpdateNameInput.assert(raw)).mutation(({ input }) => saveName(input.name)),
});electron/preload.ts— the bridge (fixed, callsexposeElectronTRPC)electron/backend/ipcTrpc.ts— IPC handler mounting (createIPCHandler)electron/main.ts— window/bootstrap
Only touch:
electron/backend/functions/<domain>.tselectron/backend/routers/<domain>.tselectron/backend/router.ts(only when adding a brand‑new domain)
The app uses TanStack Router with hash history.
-
- Electron loads
index.htmlfrom a file URL or via the dev server. Hash history avoids server‑side route handling and 404s when the app is refreshed or a deep link is opened. - It works reliably across dev and production without custom protocol handlers.
- Electron loads
-
src/main.tsxusescreateHashHistory()when creating the router.
import { createHashHistory, createRouter } from '@tanstack/react-router';
import { routeTree } from '@web/routeTree.gen';
const router = createRouter({ routeTree, history: createHashHistory() });Note
You can switch to a custom history (e.g., file/protocol) later if you add deep‑linking via app.setAsDefaultProtocolClient and handle it in the main process.
@web/*→src/*@app/*→electron/*
Configured in vite.config.ts (resolve.alias) and TypeScript paths.
Use them to avoid long relative imports. All code snippets in this README use these aliases, e.g.,
import { trpc } from '@web/lib/trpcClient';
import { appRouter } from '@app/backend/router';This template uses React Query to call tRPC procedures and render from cached data.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
{/* Your routed app is rendered via RouterProvider */}
{/* Components can use useQuery + tRPC calls */}
</QueryClientProvider>
);import { useQuery } from '@tanstack/react-query';
import { trpc } from '@web/lib/trpcClient';
const { data, refetch } = useQuery({
queryKey: ['osInfo'],
queryFn: () => trpc.os.getInfo.query(),
// That basically mirrors trpc.router.attribute.query()
staleTime: Infinity,
});React Query Devtools and TanStack Router Devtools are loaded only in development to keep production bundles lean.
They are lazy‑imported and gated by import.meta.env.DEV in src/main.tsx.
// src/main.tsx
import { lazy, Suspense } from 'react';
const DevTools = import.meta.env.DEV ? lazy(() => import('@web/components/utils/devtools')) : null;
{
DevTools ? (
<Suspense fallback={null}>
<DevTools router={router} />
</Suspense>
) : null;
}Tip
This avoids bundling devtools in production while keeping them ergonomic in development.
This template uses TailwindCSS v4 for styling.
React Icons is included for convenient icon usage across the app. Import any pack/icon you need:
import { VscServer } from 'react-icons/vsc';
import { FaMicrochip } from 'react-icons/fa6';No special setup required.
This template enables the React Compiler through the React plugin's Babel configuration in vite.config.ts.
It targets React 19 semantics and works out of the box with React 19.
If you run into issues with third‑party libraries, you can temporarily disable the compiler by removing the Babel plugin entry.
Important
Security: contextIsolation: true, nodeIntegration: false. Only a minimal, typed surface is exposed via preload.
- Config:
electron-builder.json5 - Build Windows installer:
pnpm buildNote
Default Electron icon is used if you don’t provide icons. To customize, drop icon.ico/icon.icns/icon.png at project root per config.
After a build, artifacts are written to release/<version>/.
- Windows: ship the NSIS installer
.exe(named likeElectron Base-Windows-<version>-Setup.exe). - macOS: ship the
.dmg(if configured inmac.target). - Linux: ship the
.AppImage(and/or.debif configured).
Important
Do NOT distribute any *-unpacked/ folders, nor dist/ or dist-electron/. Those are for local testing only.
- Preload path mismatch (bridge not initialized)
- Ensure
electron/main.tsusespreload: path.join(__dirname, 'preload.mjs')andvite.config.tspreload entry iselectron/preload.ts.
- Ensure
- Electron postinstall blocked by pnpm
- Run
pnpm approve-builds electron.
- Run
- Installer icon not applied
- Provide root-level icons or update
electron-builder.json5paths.
- Provide root-level icons or update