The short version
- Electron needs two webpack builds: the main process with the electron-main target, and the React renderer with the web or electron-renderer target.
- In development the Electron window loads the webpack dev server at localhost:3000. In production it loads the index.html that webpack builds into dist.
- Keep .js in webpack's resolve.extensions even in a TypeScript project, because the packages in node_modules ship JavaScript.
- Set webpack's mode from NODE_ENV instead of hard-coding it. Webpack bakes the mode into process.env.NODE_ENV at build time, so a hard-coded development mode makes a packaged app look for localhost:3000.
- Put build tools in devDependencies, because electron-builder copies everything in dependencies into the packaged app.
To set up Electron with React and webpack, you give webpack two configs: one compiles the main process with the electron-main target, and the other compiles the React renderer for the web. In development, the Electron window loads the webpack dev server at localhost:3000. In production, it loads the index.html that webpack builds into dist.
Below I build that starter from an empty folder with TypeScript and Tailwind v4, all the way to an installer you can hand to users. I tested every file on this page on Linux with Electron 44.7 and 45.0, and the finished project is on GitHub at hassanaziz0012/electron-react-starter. The video above builds the same starter on Windows with Electron 37.
Why I built my own starter
I'm building a Discord clone with a Go backend, and I wanted Electron for the desktop app because it's the simplest way I know to ship one. The Electron docs don't say much about using it with React or any other framework, and I need React, because I don't want to repeat the same HTML again and again.
None of the starter templates I found fit. Some weren't in TypeScript, and I hate working without type definitions. Others didn't have Tailwind, and the one that did used Tailwind 3. So I built my own.
Electron's main and renderer processes
Every Electron app has one main process. It runs in Node.js and handles the backend side of the app, including creating windows. Each window then loads a web page in its own renderer process, which is basically a Chromium tab. That's where React runs.
The main process needs Node's modules and Electron's APIs, while the renderer is a web page that gets bundled like any other React app. So they need different builds, and this setup has two webpack configs with two entry files: main.ts and renderer.tsx.
This setup vs Electron Forge vs electron-vite
If you'd rather not wire up webpack yourself, here's how the ready-made options compare:
| By hand (this page) | Electron Forge | electron-vite | electron-react-boilerplate | |
|---|---|---|---|---|
| Bundler | webpack | webpack or Vite (Vite support is marked experimental) | Vite | electron-vite |
| Packaging | electron-builder | Forge's own makers | electron-builder in its templates | electron-builder |
| How you start |
npm init and the files below |
npx create-electron-app@latest with a template |
npm create @quick-start/electron@latest |
Clone the repo |
| Tailwind | v4, set up below | Add it yourself | Add it yourself | Add it yourself |
All four work. The by-hand version has more files, but you know what every one of them does.
Install the dependencies
Create a folder for the project and an npm package inside it:
mkdir electron-react-starter
cd electron-react-starter
npm init -y
React and React DOM are the only regular dependencies. Everything else only runs while you develop or build, so it goes in devDependencies:
npm install react react-dom
npm install -D typescript@6 electron electron-builder electronmon webpack webpack-cli webpack-dev-server ts-loader html-webpack-plugin mini-css-extract-plugin css-loader postcss postcss-loader tailwindcss @tailwindcss/postcss concurrently wait-on cross-env @types/react @types/react-dom @types/node
electron-builder copies everything in dependencies into the packaged app, even though webpack has already bundled it, so a build tool in there only makes the installer bigger. In the video I put Tailwind and the CSS loaders in dependencies; here they're dev dependencies.
Install TypeScript 6, not 7. ts-loader calls the TypeScript compiler API, and the TypeScript 7 package doesn't have one, so with 7 the build fails.
The video also installs autoprefixer. Tailwind v4 adds vendor prefixes itself, so you can leave it out.
The project structure
electron-react-starter/
├── public/
│ └── index.html
├── src/
│ ├── styles/
│ │ └── global.css
│ ├── App.tsx
│ ├── global.d.ts
│ ├── main.ts
│ └── renderer.tsx
├── webpack/
│ ├── webpack.main.js
│ └── webpack.renderer.js
├── package.json
├── postcss.config.js
└── tsconfig.json
Webpack writes the compiled files to dist, and electron-builder puts the installer in build. Both get created for you.
TypeScript config
Here's tsconfig.json:
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["DOM", "ESNext"],
"jsx": "react-jsx",
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}
"jsx": "react-jsx" is what lets you write JSX at all. Webpack takes care of resolving imports, so "moduleResolution": "bundler" tells TypeScript to resolve them the way a bundler does. The video uses "module": "commonjs" with "moduleResolution": "node", which TypeScript 6 now rejects as deprecated.
TypeScript also needs to know that importing a CSS file is fine. That's src/global.d.ts, which is one line:
declare module "*.css";
Without it, TypeScript 6 stops the build at the import "./styles/global.css" line in the renderer.
The main process
Here's src/main.ts:
import { app, BrowserWindow } from "electron";
import path from "path";
const env = process.env.NODE_ENV;
function createWindow() {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
if (env === "development") {
win.loadURL("http://localhost:3000");
win.webContents.openDevTools();
} else {
win.loadFile(path.join(__dirname, "../renderer/index.html"));
}
}
app.whenReady().then(createWindow);
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
In development the window loads the React dev server and opens DevTools. In production it loads the index.html that webpack builds. __dirname is dist/main once this file is compiled, hence the ../renderer.
In the video I said to check the docs on nodeIntegration and contextIsolation, because I had no idea what they did. With nodeIntegration: false, the web page can't call require or any other Node.js API. With contextIsolation: true, a preload script, if you add one later, runs in a separate JavaScript context from the page. Both are Electron's defaults (since Electron 5 and 12), so you could delete those lines, but I like being able to see them.
The window-all-closed handler quits the app when you close the last window. darwin is macOS, where apps usually stay open with no windows, so it skips the quit there.
The renderer and the React app
src/renderer.tsx mounts React into the page:
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles/global.css";
const root = createRoot(document.getElementById("root") as HTMLElement);
root.render(<App />);
Import the CSS here. Webpack only builds files that something imports, so without this line your styles never make it into the app. The file has to end in .tsx because it contains JSX; I named it .ts at first in the video and couldn't work out why TypeScript kept complaining.
src/App.tsx is any React component you like. Mine is just enough to show that Tailwind works:
export default function App() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 bg-slate-900 text-white">
<h1 className="text-4xl font-bold">Electron + React</h1>
<p className="text-slate-300">TypeScript, Tailwind v4 and webpack.</p>
</main>
);
}
src/styles/global.css holds the one line Tailwind v4 needs:
@import "tailwindcss";
And public/index.html is the page template, the way Create React App does it:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Electron React Starter</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
The <title> becomes the window title, so change it for your own app. The root div is where React renders.
The main process webpack config
Here's webpack/webpack.main.js:
const path = require("path");
module.exports = {
mode: process.env.NODE_ENV === "production" ? "production" : "development",
target: "electron-main",
entry: "./src/main.ts",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "../dist/main"),
},
resolve: {
extensions: [".ts", ".tsx", ".js"],
},
module: {
rules: [
{
test: /\.tsx?$/,
use: "ts-loader",
exclude: /node_modules/,
},
],
},
};
target: "electron-main" tells webpack this code runs in Electron's main process, so it leaves electron and Node's built-in modules alone instead of trying to bundle them.
Keep .js in extensions. In the video I said you only need .ts and .tsx because the code is all TypeScript, and the app broke. Your own code is TypeScript, but the packages in node_modules ship JavaScript, and webpack has to resolve those too.
Set mode from NODE_ENV too, and don't hard-code it. Webpack replaces process.env.NODE_ENV in your code with the mode at build time. I tried a build with mode: "development" hard-coded, and even with NODE_ENV=production the bundle came out with "development" baked into main.ts. A packaged app built like that would try to load localhost:3000, where nothing is running.
The renderer webpack config
Here's webpack/webpack.renderer.js:
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
mode: process.env.NODE_ENV === "production" ? "production" : "development",
target: "web",
entry: "./src/renderer.tsx",
output: {
filename: "renderer.js",
path: path.resolve(__dirname, "../dist/renderer"),
clean: true,
},
resolve: {
extensions: [".ts", ".tsx", ".js"],
},
module: {
rules: [
{
test: /\.tsx?$/,
use: "ts-loader",
exclude: /node_modules/,
},
{
test: /\.css$/,
include: path.resolve(__dirname, "../src/styles"),
use: [MiniCssExtractPlugin.loader, "css-loader", "postcss-loader"],
},
],
},
plugins: [
new HtmlWebpackPlugin({ template: "./public/index.html" }),
new MiniCssExtractPlugin({ filename: "output.css" }),
],
devServer: {
static: path.resolve(__dirname, "../dist/renderer"),
hot: true,
port: 3000,
},
};
The target can be web or electron-renderer. The renderer is a Chromium page with no Node.js access, so either works, and I use web. clean: true empties dist/renderer before each build, so old files don't pile up next to the new ones.
Loaders run right to left, so postcss-loader runs Tailwind on global.css first and MiniCssExtractPlugin.loader writes the result out as output.css last. HtmlWebpackPlugin adds the script and stylesheet tags to public/index.html for you. The devServer block serves the renderer on port 3000 and updates the page when you save.
PostCSS needs one plugin, set in postcss.config.js in the project root:
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
},
};
The npm scripts
Here's the whole package.json:
{
"name": "electron-react-starter",
"version": "1.0.0",
"description": "Electron + React + TypeScript + Tailwind v4, built with webpack",
"author": "Hassan Aziz",
"main": "dist/main/bundle.js",
"scripts": {
"start:electron": "electronmon .",
"dev:main": "webpack --config webpack/webpack.main.js --watch",
"dev:renderer": "webpack serve --config webpack/webpack.renderer.js",
"dev": "cross-env NODE_ENV=development concurrently --names \"react,main,electron\" --prefix-colors \"blue,red,magenta\" --kill-others --kill-others-on-fail \"npm run dev:renderer\" \"npm run dev:main\" \"wait-on http://localhost:3000 dist/main/bundle.js && npm run start:electron\"",
"build:main": "webpack --config webpack/webpack.main.js",
"build:renderer": "webpack --config webpack/webpack.renderer.js",
"build": "cross-env NODE_ENV=production npm run build:main && cross-env NODE_ENV=production npm run build:renderer",
"package": "npm run build && electron-builder"
},
"build": {
"appId": "com.hassanaziz.electronreactstarter",
"productName": "Electron React Starter",
"directories": {
"output": "build"
},
"files": [
"dist/**/*"
],
"win": {
"target": "nsis"
},
"mac": {
"target": "dmg"
},
"linux": {
"target": "AppImage"
}
},
"electronmon": {
"patterns": [
"!src/**",
"!build/**"
]
},
"dependencies": {
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^26.6.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"concurrently": "^10.0.6",
"cross-env": "^10.1.0",
"css-loader": "^7.1.5",
"electron": "^44.7.0",
"electron-builder": "^26.15.3",
"electronmon": "^2.0.4",
"html-webpack-plugin": "^5.6.8",
"mini-css-extract-plugin": "^2.10.2",
"postcss": "^8.5.29",
"postcss-loader": "^8.2.1",
"tailwindcss": "^4.3.3",
"ts-loader": "^9.6.2",
"typescript": "^6.0.3",
"wait-on": "^9.5.1",
"webpack": "^5.111.1",
"webpack-cli": "^7.2.3",
"webpack-dev-server": "^6.0.0"
}
}
main points Electron at the compiled main process, so change it from the index.js that npm init wrote.
dev is the long one. cross-env sets NODE_ENV with one syntax that works everywhere, because Windows and Unix shells set environment variables differently. concurrently runs the quoted commands at the end side by side, and --names and --prefix-colors label each line of output so you can tell which one printed it. wait-on holds Electron back until the dev server answers on port 3000 and the main bundle exists, so it never opens on an empty page. --kill-others and --kill-others-on-fail stop everything if one of them crashes, because a React server with no app is useless, and so is an app with no React server.
build sets NODE_ENV to production and runs both webpack configs once. package builds and then runs electron-builder, which reads the build block. appId and productName identify your app, so change both. files tells it the app only needs dist, and the platform keys pick the installer type for each OS.
Reload the main process with electronmon
The dev server only reloads the React side. A change to main.ts gets recompiled by webpack, but Electron keeps running the old code until you restart it.
In the video I used electron-reloader for this. Version 2, released in October 2026, needs import.meta and restarts the app by starting a new Electron process and exiting the old one. When I tried it, the new window ended up detached from npm run dev, and Ctrl+C no longer closed it. electronmon does the same job from a parent process that stays put: the start:electron script runs electronmon . instead of electron ., and it restarts the app when the main bundle changes.
The electronmon block in package.json tells it to ignore src and build. Webpack already turns changes in src into a new bundle or a page reload, and build is electron-builder's output.
Run it
npm run dev
The first run downloads the Electron binary, so give it a minute. You'll see logs in three colors, one per process, and then an 800 by 600 window with DevTools open and "Electron + React" in white on a dark background. Change the heading in App.tsx and save, and the window shows the new text a second later. Change the window width in main.ts and save, and the app restarts at the new size.
Stop everything with Ctrl+C. Closing the window doesn't end the session any more: electronmon keeps waiting for the next change, so you can close the window and edit without restarting npm run dev.
If the React logs show "Module not found: Error: Can't resolve 'scheduler'", check that .js is in resolve.extensions in both webpack configs. That's the error I hit in the video, and Electron shows a page full of errors until you fix it.
Build and package the app
npm run package
This builds both processes in production mode, then electron-builder packages the app into build. On my Linux machine it took under a minute and gave me an AppImage of about 120 MB, most of which is Chromium.
On Windows there are two more steps, from the video. Install Visual Studio (not VS Code) with its build tools, or electron-builder stops with an error telling you to install them. And run the first npm run package from a terminal opened as administrator, since electron-builder creates symbolic links on its first run, and on Windows that takes admin rights. After that, a normal terminal is fine.
While I was recording the video, a new minor version of Electron came out and broke packaging. I spent a long time debugging it, and the fix was to pin Electron back to 37.2.4, the version I'd built the template on. Minor versions aren't supposed to break other packages, but this one did. With the versions on this page, packaging worked first time, but if npm run package suddenly fails after an update, pin Electron to the last version that worked before you dig any deeper.
Build it yourself at least once
Electron, React, TypeScript and Tailwind is my favorite stack, and now I have a starter that's exactly what I want. You could grab a template instead, and for a quick project that's fine. But I'd build it from scratch at least once, because that's how you find out why there are two webpack configs and where the window gets its HTML in production. There are something like 50 steps, and I still kept the old project open next to me as a reference while I recorded.
Originally published on hassandev.me.
Top comments (0)